Cloud Storage Hexagonal Architecture Guard
Use this skill whenever you add, change, or review Rust code under crates/** or services/** that touches a crate with src/domain, src/inbound, or src/outbound.
This repository follows the ports-and-adapters / hexagonal style described in Master Hexagonal Architecture in Rust and the howtocodeit/hexarch 3-simple-service branch: domain models + ports + services are the center; inbound and outbound adapters are replaceable shells around that center.
Non-negotiable dependency rule
Dependencies point inward:
inbound adapters ──► domain ports/models/services ◄── outbound adapters
composition root ──► inbound + domain service + outbound implementations
domain/ must not depend on inbound/, outbound/, axum, HTTP response types, SQLx pools/queries, AWS SDKs, Redis, reqwest, environment variables, or transport DTOs.
inbound/ may depend on domain ports/models/services. It must not own business decisions or persistence/external-service implementation details.
outbound/ implements domain ports for databases, S3, HTTP clients, queues, metrics, etc. It must not own use-case policy.
- Wiring concrete adapters into services belongs in the composition root / builder, not inside domain logic or handlers.
Layer responsibilities
Domain (src/domain/**)
Put the following here:
- Domain models, value objects, command/request types, response types, and domain errors.
- Service/use-case traits exposed to inbound adapters.
- Concrete domain service implementations that orchestrate a use case.
- Port traits for required capabilities: repositories, authorizers, notifiers, event publishers, clocks, ID generators, metrics, external domain services.
- Business invariants, state transitions, authorization policy, ownership checks, permission-level checks, tenant/team/workspace policy, filtering rules, and side-effect orchestration.
Inbound adapters (src/inbound/**)
Axum handlers, AI tools, Kafka/listener handlers, lambda handlers, and CLI entrypoints are adapters. Keep them thin:
- Extract authentication/identity from transport (
MacroAuthorizationExtractor, OptionalMacroAuthorizationExtractor, JWT, signed internal header, request context).
- Parse path/query/body/header data and perform transport/syntax validation.
- Convert transport DTOs into domain request/command types.
- Call exactly the appropriate domain service/port method.
- Convert domain success/errors into transport responses/status codes/tool output.
Inbound adapters must not:
- Decide whether a user may access/edit/delete/share/list an entity.
- Call
entity_access, roles_and_permissions, repositories, SQLx, S3, Redis, SQS, reqwest, or other outbound implementations to make a use-case decision.
- Branch on
AccessLevel, role, owner/admin/member, tenant/team membership, project membership, subscription tier, feature entitlement, entity state, or ownership for business policy.
- Filter returned entities by permissions or hide fields based on authz rules.
- Start transactions or compose multiple persistence/external calls as the core use case.
Outbound adapters (src/outbound/**)
Put implementation details here:
- SQLx queries and transaction mechanics.
- AWS/Redis/OpenSearch/HTTP client calls.
- Mapping external errors to domain errors as required by a port contract.
- Implementing repository/authorizer/client/notifier ports.
Outbound adapters must not:
- Import
crate::inbound::*, axum extractors/responses, or transport DTOs.
- Invent business policy beyond faithfully implementing the domain port contract.
- Decide use-case flow; return facts/capabilities/results for the domain service to decide.
Authorization and EntityAccessReceipt rule
Authentication can happen at the edge. Entity access checks should cross the boundary as a typed capability: EntityAccessReceipt<T>.
EntityAccessReceipt<T> means the entity access layer has verified the caller has at least permission T for the entity. Inbound adapters may obtain this receipt through the standard access extractors or by calling the entity access service specifically to mint a receipt. After that, ordinary handlers/tools/listeners must pass the receipt inward instead of re-checking or branching on authorization.
Allowed in inbound:
- Reject missing/invalid credentials (
401 / unauthenticated).
- Extract
actor, request_context, user_id, service identity, internal principal, or a typed EntityAccessReceipt<T>.
- Use standard access extractors or
generate_entity_access_receipt::<RequiredLevel>(...) to mint a receipt.
- Pass the receipt and parsed request data into the domain service call.
- Convert domain/access errors into transport responses.
Forbidden in ordinary inbound handlers/tools/listeners:
if user_id != owner_id { ... }
if access_level < Edit { ... }
entity_access_service.get_access_level(...) or can_edit(...) followed by allow/deny branching.
- Role/team/tenant/project permission checks.
- Inspecting
receipt.entity_permission() to decide use-case business policy.
- Direct repository/persistence calls for the protected action.
Correct pattern:
- Pick the minimum required permission type for the use case, e.g.
ViewAccessLevel, EditAccessLevel, or OwnerAccessLevel.
- In inbound, obtain
EntityAccessReceipt<RequiredLevel> using the existing entity access boundary.
- Pass that receipt to the domain service method.
- In the domain service, perform use-case-specific policy that is not captured by the minimum receipt type, e.g. owner-only share changes inside an edit operation.
- Return a domain error such as
Unauthorized, Forbidden, or a typed policy error.
- Let inbound map that domain/access error to HTTP/tool/listener semantics.
- Unit-test allow and deny cases at the domain service level with fake receipts/ports.
Bad vs good
Bad: the handler branches on permissions and performs the protected action itself.
pub async fn edit_document_handler(
State(state): State<DocumentRouterState>,
Json(args): Json<EditDocumentServiceArgs>,
) -> Result<Json<EditDocumentResponse>, DocumentError> {
let access_level = state
.entity_access
.get_access_level(current_user(), &args.document_id, EntityType::Document)
.await?
.ok_or(DocumentError::Unauthorized)?;
if access_level < AccessLevel::Edit {
return Err(DocumentError::Unauthorized);
}
if args.share_permission.is_some() && access_level != AccessLevel::Owner {
return Err(DocumentError::Unauthorized);
}
state.document_repo.update_document(args).await?;
Ok(Json(EditDocumentResponse::success()))
}
Good: inbound obtains a typed receipt and forwards it; the domain service owns policy and orchestration.
pub async fn edit_document_handler<T: DocumentService, Svc: EntityAccessService>(
access: DocumentAccessExtractor<EditAccessLevel, Svc>,
State(state): State<DocumentRouterState<T, Svc>>,
document_context: LoadedDocumentBasic,
project: ProjectBodyAccessLevelExtractor<EditAccessLevel, EditDocumentServiceArgs, Svc>,
) -> Result<Json<EditDocumentResponse>, DocumentError> {
state
.service
.edit_document(
access.entity_access_receipt,
document_context.into_inner(),
project.into_inner(),
)
.await?;
Ok(Json(EditDocumentResponse::success()))
}
async fn edit_document(
&self,
receipt: EntityAccessReceipt<EditAccessLevel>,
document_context: DocumentBasic,
args: EditDocumentServiceArgs,
) -> Result<(), DocumentError> {
if let EntityPermission::AccessLevel { access_level } = receipt.entity_permission() {
if args.project_id.is_some() && *access_level != AccessLevel::Owner {
return Err(DocumentError::Unauthorized);
}
if args.share_permission.is_some() && *access_level != AccessLevel::Owner {
return Err(DocumentError::Unauthorized);
}
}
let document_id = receipt.entity().entity_id.clone();
self.repo
.edit_document_metadata(document_id, document_context, args)
.await
}
Pre-write checklist
Before editing code, classify each touched file:
- Is it
domain, inbound, outbound, or composition/wiring?
- What use case is being added or changed?
- What domain command/model/error represents it?
- Which domain service method should inbound call?
- Which outbound capabilities are needed, and are they behind domain port traits?
- What authorization/policy decisions are needed, and where will domain service tests cover them?
If a step has no answer, stop and design that boundary before writing code.
Review checklist
For every diff under crates/** or services/**, reject or refactor if any of these are true:
src/domain/** imports axum, http::StatusCode, IntoResponse, Json, Router, Request, HeaderMap, SQLx pools/queries, AWS SDK clients, Redis clients, reqwest clients, crate::inbound, or crate::outbound.
src/inbound/** contains SQLx queries, transaction handling, repository calls, AWS/Redis/OpenSearch/reqwest calls, or direct calls to outbound implementations.
- Ordinary
src/inbound/** handlers/tools/listeners contain authorization decisions (AccessLevel, role checks, owner checks, team/project membership checks, can_*, authorize_*, ensure_*permission*) instead of forwarding a typed EntityAccessReceipt<T> or identity to a service. Dedicated access extractors whose job is to mint receipts are the exception.
- Handlers return domain-specific decisions not produced by a domain service.
- Outbound code imports inbound/transport DTOs or axum types.
- A domain service depends on concrete adapters rather than port traits/generic bounds/trait objects.
- Tests only cover HTTP status mapping and do not cover service-level allow/deny/business-rule cases.
Useful inspection commands
Set CRATE to the crate you are touching, for example CRATE=crates/documents.
# Domain must not know transport or concrete infrastructure.
rg -n "use (axum|http::StatusCode)|IntoResponse|Json<|Router|HeaderMap|Request<|sqlx::|PgPool|aws_sdk|redis::|reqwest|crate::inbound|crate::outbound" "$CRATE/src/domain" --glob '*.rs'
# Inbound authz/policy hits require inspection. Receipt-minting extractors are allowed;
# ordinary handlers should forward EntityAccessReceipt<T> instead of branching.
rg -n "entity_access|EntityAccessReceipt|roles_and_permissions|AccessLevel|RoleId|owner|admin|member|tenant|team|project|permission|authorize|authz|can_|ensure_.*permission|Forbidden|Unauthorized" "$CRATE/src/inbound" --glob '*.rs'
# Inbound should not do persistence or infrastructure work.
rg -n "sqlx::|query!|query_as!|PgPool|Transaction|aws_sdk|redis::|opensearch|reqwest|S3|Sqs|Dynamo" "$CRATE/src/inbound" --glob '*.rs'
# Outbound must not depend on inbound transport.
rg -n "crate::inbound|axum|IntoResponse|Json<|Router|StatusCode" "$CRATE/src/outbound" --glob '*.rs'
rg hits are not automatically failures, but every hit must be explained by layer responsibilities. When in doubt, move policy inward.
If you find an existing violation
- Do not add more logic to the violating adapter.
- If the task touches that use case, prefer moving the policy/orchestration into the domain service as part of the change.
- If a full refactor is large or risky, stop and ask the user before making sweeping changes. Offer the smallest compliant plan that prevents new violations.
Final response requirement
When you use this skill, explicitly state that the hexagonal boundary was checked and summarize where authz/business policy lives after your change.
1---2name: cloud-storage-hexagonal-architecture3description: Enforce hexagonal architecture in the Rust backend. Use before modifying crates or Rust services, especially inbound axum/tool/listener adapters, domain services/ports, outbound adapters, authorization, permissions, database access, or external clients.4---56# Cloud Storage Hexagonal Architecture Guard78Use this skill whenever you add, change, or review Rust code under `crates/**` or `services/**` that touches a crate with `src/domain`, `src/inbound`, or `src/outbound`.910This repository follows the ports-and-adapters / hexagonal style described in _Master Hexagonal Architecture in Rust_ and the `howtocodeit/hexarch` `3-simple-service` branch: domain models + ports + services are the center; inbound and outbound adapters are replaceable shells around that center.1112## Non-negotiable dependency rule1314Dependencies point inward:1516```text17inbound adapters ──► domain ports/models/services ◄── outbound adapters18composition root ──► inbound + domain service + outbound implementations19```2021- `domain/` must not depend on `inbound/`, `outbound/`, `axum`, HTTP response types, SQLx pools/queries, AWS SDKs, Redis, reqwest, environment variables, or transport DTOs.22- `inbound/` may depend on domain ports/models/services. It must not own business decisions or persistence/external-service implementation details.23- `outbound/` implements domain ports for databases, S3, HTTP clients, queues, metrics, etc. It must not own use-case policy.24- Wiring concrete adapters into services belongs in the composition root / builder, not inside domain logic or handlers.2526## Layer responsibilities2728### Domain (`src/domain/**`)2930Put the following here:3132- Domain models, value objects, command/request types, response types, and domain errors.33- Service/use-case traits exposed to inbound adapters.34- Concrete domain service implementations that orchestrate a use case.35- Port traits for required capabilities: repositories, authorizers, notifiers, event publishers, clocks, ID generators, metrics, external domain services.36- Business invariants, state transitions, authorization policy, ownership checks, permission-level checks, tenant/team/workspace policy, filtering rules, and side-effect orchestration.3738### Inbound adapters (`src/inbound/**`)3940Axum handlers, AI tools, Kafka/listener handlers, lambda handlers, and CLI entrypoints are adapters. Keep them thin:4142- Extract authentication/identity from transport (`MacroAuthorizationExtractor`, `OptionalMacroAuthorizationExtractor`, JWT, signed internal header, request context).43- Parse path/query/body/header data and perform transport/syntax validation.44- Convert transport DTOs into domain request/command types.45- Call exactly the appropriate domain service/port method.46- Convert domain success/errors into transport responses/status codes/tool output.4748Inbound adapters must not:4950- Decide whether a user may access/edit/delete/share/list an entity.51- Call `entity_access`, `roles_and_permissions`, repositories, SQLx, S3, Redis, SQS, reqwest, or other outbound implementations to make a use-case decision.52- Branch on `AccessLevel`, role, owner/admin/member, tenant/team membership, project membership, subscription tier, feature entitlement, entity state, or ownership for business policy.53- Filter returned entities by permissions or hide fields based on authz rules.54- Start transactions or compose multiple persistence/external calls as the core use case.5556### Outbound adapters (`src/outbound/**`)5758Put implementation details here:5960- SQLx queries and transaction mechanics.61- AWS/Redis/OpenSearch/HTTP client calls.62- Mapping external errors to domain errors as required by a port contract.63- Implementing repository/authorizer/client/notifier ports.6465Outbound adapters must not:6667- Import `crate::inbound::*`, axum extractors/responses, or transport DTOs.68- Invent business policy beyond faithfully implementing the domain port contract.69- Decide use-case flow; return facts/capabilities/results for the domain service to decide.7071## Authorization and `EntityAccessReceipt` rule7273Authentication can happen at the edge. Entity access checks should cross the boundary as a typed capability: `EntityAccessReceipt<T>`.7475`EntityAccessReceipt<T>` means the entity access layer has verified the caller has at least permission `T` for the entity. Inbound adapters may obtain this receipt through the standard access extractors or by calling the entity access service specifically to mint a receipt. After that, ordinary handlers/tools/listeners must pass the receipt inward instead of re-checking or branching on authorization.7677Allowed in inbound:7879- Reject missing/invalid credentials (`401` / unauthenticated).80- Extract `actor`, `request_context`, `user_id`, service identity, internal principal, or a typed `EntityAccessReceipt<T>`.81- Use standard access extractors or `generate_entity_access_receipt::<RequiredLevel>(...)` to mint a receipt.82- Pass the receipt and parsed request data into the domain service call.83- Convert domain/access errors into transport responses.8485Forbidden in ordinary inbound handlers/tools/listeners:8687- `if user_id != owner_id { ... }`88- `if access_level < Edit { ... }`89- `entity_access_service.get_access_level(...)` or `can_edit(...)` followed by allow/deny branching.90- Role/team/tenant/project permission checks.91- Inspecting `receipt.entity_permission()` to decide use-case business policy.92- Direct repository/persistence calls for the protected action.9394Correct pattern:95961. Pick the minimum required permission type for the use case, e.g. `ViewAccessLevel`, `EditAccessLevel`, or `OwnerAccessLevel`.972. In inbound, obtain `EntityAccessReceipt<RequiredLevel>` using the existing entity access boundary.983. Pass that receipt to the domain service method.994. In the domain service, perform use-case-specific policy that is not captured by the minimum receipt type, e.g. owner-only share changes inside an edit operation.1005. Return a domain error such as `Unauthorized`, `Forbidden`, or a typed policy error.1016. Let inbound map that domain/access error to HTTP/tool/listener semantics.1027. Unit-test allow and deny cases at the domain service level with fake receipts/ports.103104## Bad vs good105106Bad: the handler branches on permissions and performs the protected action itself.107108```rust109pub async fn edit_document_handler(110 State(state): State<DocumentRouterState>,111 Json(args): Json<EditDocumentServiceArgs>,112) -> Result<Json<EditDocumentResponse>, DocumentError> {113 let access_level = state114 .entity_access115 .get_access_level(current_user(), &args.document_id, EntityType::Document)116 .await?117 .ok_or(DocumentError::Unauthorized)?;118119 if access_level < AccessLevel::Edit {120 return Err(DocumentError::Unauthorized);121 }122123 if args.share_permission.is_some() && access_level != AccessLevel::Owner {124 return Err(DocumentError::Unauthorized);125 }126127 state.document_repo.update_document(args).await?;128 Ok(Json(EditDocumentResponse::success()))129}130```131132Good: inbound obtains a typed receipt and forwards it; the domain service owns policy and orchestration.133134```rust135pub async fn edit_document_handler<T: DocumentService, Svc: EntityAccessService>(136 access: DocumentAccessExtractor<EditAccessLevel, Svc>,137 State(state): State<DocumentRouterState<T, Svc>>,138 document_context: LoadedDocumentBasic,139 project: ProjectBodyAccessLevelExtractor<EditAccessLevel, EditDocumentServiceArgs, Svc>,140) -> Result<Json<EditDocumentResponse>, DocumentError> {141 state142 .service143 .edit_document(144 access.entity_access_receipt,145 document_context.into_inner(),146 project.into_inner(),147 )148 .await?;149150 Ok(Json(EditDocumentResponse::success()))151}152```153154```rust155async fn edit_document(156 &self,157 receipt: EntityAccessReceipt<EditAccessLevel>,158 document_context: DocumentBasic,159 args: EditDocumentServiceArgs,160) -> Result<(), DocumentError> {161 if let EntityPermission::AccessLevel { access_level } = receipt.entity_permission() {162 if args.project_id.is_some() && *access_level != AccessLevel::Owner {163 return Err(DocumentError::Unauthorized);164 }165166 if args.share_permission.is_some() && *access_level != AccessLevel::Owner {167 return Err(DocumentError::Unauthorized);168 }169 }170171 let document_id = receipt.entity().entity_id.clone();172 self.repo173 .edit_document_metadata(document_id, document_context, args)174 .await175}176```177178## Pre-write checklist179180Before editing code, classify each touched file:1811821. Is it `domain`, `inbound`, `outbound`, or composition/wiring?1832. What use case is being added or changed?1843. What domain command/model/error represents it?1854. Which domain service method should inbound call?1865. Which outbound capabilities are needed, and are they behind domain port traits?1876. What authorization/policy decisions are needed, and where will domain service tests cover them?188189If a step has no answer, stop and design that boundary before writing code.190191## Review checklist192193For every diff under `crates/**` or `services/**`, reject or refactor if any of these are true:194195- `src/domain/**` imports `axum`, `http::StatusCode`, `IntoResponse`, `Json`, `Router`, `Request`, `HeaderMap`, SQLx pools/queries, AWS SDK clients, Redis clients, reqwest clients, `crate::inbound`, or `crate::outbound`.196- `src/inbound/**` contains SQLx queries, transaction handling, repository calls, AWS/Redis/OpenSearch/reqwest calls, or direct calls to outbound implementations.197- Ordinary `src/inbound/**` handlers/tools/listeners contain authorization decisions (`AccessLevel`, role checks, owner checks, team/project membership checks, `can_*`, `authorize_*`, `ensure_*permission*`) instead of forwarding a typed `EntityAccessReceipt<T>` or identity to a service. Dedicated access extractors whose job is to mint receipts are the exception.198- Handlers return domain-specific decisions not produced by a domain service.199- Outbound code imports inbound/transport DTOs or axum types.200- A domain service depends on concrete adapters rather than port traits/generic bounds/trait objects.201- Tests only cover HTTP status mapping and do not cover service-level allow/deny/business-rule cases.202203## Useful inspection commands204205Set `CRATE` to the crate you are touching, for example `CRATE=crates/documents`.206207```bash208# Domain must not know transport or concrete infrastructure.209rg -n "use (axum|http::StatusCode)|IntoResponse|Json<|Router|HeaderMap|Request<|sqlx::|PgPool|aws_sdk|redis::|reqwest|crate::inbound|crate::outbound" "$CRATE/src/domain" --glob '*.rs'210211# Inbound authz/policy hits require inspection. Receipt-minting extractors are allowed;212# ordinary handlers should forward EntityAccessReceipt<T> instead of branching.213rg -n "entity_access|EntityAccessReceipt|roles_and_permissions|AccessLevel|RoleId|owner|admin|member|tenant|team|project|permission|authorize|authz|can_|ensure_.*permission|Forbidden|Unauthorized" "$CRATE/src/inbound" --glob '*.rs'214215# Inbound should not do persistence or infrastructure work.216rg -n "sqlx::|query!|query_as!|PgPool|Transaction|aws_sdk|redis::|opensearch|reqwest|S3|Sqs|Dynamo" "$CRATE/src/inbound" --glob '*.rs'217218# Outbound must not depend on inbound transport.219rg -n "crate::inbound|axum|IntoResponse|Json<|Router|StatusCode" "$CRATE/src/outbound" --glob '*.rs'220```221222`rg` hits are not automatically failures, but every hit must be explained by layer responsibilities. When in doubt, move policy inward.223224## If you find an existing violation225226- Do not add more logic to the violating adapter.227- If the task touches that use case, prefer moving the policy/orchestration into the domain service as part of the change.228- If a full refactor is large or risky, stop and ask the user before making sweeping changes. Offer the smallest compliant plan that prevents new violations.229230## Final response requirement231232When you use this skill, explicitly state that the hexagonal boundary was checked and summarize where authz/business policy lives after your change.