Skip to main content

Service-Layer Architecture and Domain Boundaries

Watch First

Why This Matters

Rust services become difficult to change when Axum handlers, SQLx queries, domain rules, worker logic, and configuration all point at each other.

Clean boundaries let web requests and background workers reuse the same use cases without duplicating business logic.

What You Will Build

Refactor the Axum CRUD API into a vertical-slice architecture:

interfaces/http -> application/services -> infrastructure/repositories -> database
workers -> application/services -> infrastructure/repositories -> database

Concept

Each layer has a job:

  • Handler: HTTP concern.
  • Service: use-case and orchestration concern.
  • Repository: persistence concern.
  • Domain model: product meaning.
  • Worker: background entry point into the same application use cases.

Rust Pattern

Use commands to make intent visible:

pub struct AssignTask {
pub task_id: TaskId,
pub assignee_id: UserId,
pub actor_id: UserId,
}

pub struct TaskService<R> {
repository: R,
}

impl<R> TaskService<R>
where
R: TaskRepository,
{
pub async fn assign(&self, command: AssignTask) -> Result<Task, TaskError> {
let mut task = self.repository.get(command.task_id).await?;
task.assign_to(command.assignee_id, command.actor_id)?;
self.repository.save(task).await
}
}

The service owns the use case. The handler and worker can both call it.

Practice

Keep this mistake out of your first implementation.

Do not put framework types in the domain:

pub fn create_task(Json(request): Json<CreateTaskRequest>) -> StatusCode {
// domain code now knows HTTP
}

Domain logic should not know about axum::Json, HTTP status codes, SQL row shapes, or database pools.

Keep these concrete mistakes out of your work.

  • Putting domain logic inside handlers.
  • Passing PgPool into pure domain functions.
  • Returning HTTP status codes from service methods.
  • Creating repository abstractions before the dependency direction is clear.

Use this sequence. Do not move to the next row until you have produced the artifact in the right column.

StepFocusArtifact
Handler vs service vs repositoryResponsibilities and boundariesResponsibility table
Commands and queriesIntent-rich inputsCreateTask, AssignTask, ListTasks
Dependency directionDomain independence, infrastructure implementationDependency diagram
Keeping frameworks out of domainNo Axum or SQL in domain logicBoundary cleanup
Simplifying lifetimes through ownershipOwned commands at boundariesSignature refactor
Designing for changeAdd route or worker without rewriting domainReused service method
Architecture diagramsRequest, worker, error, transaction, observability pathsMermaid diagrams

Build this now. Keep each change small enough that you can run cargo check, cargo test, and inspect the diff.

Pick one route that contains SQL and business rules. Refactor it into:

  • request DTO,
  • command conversion,
  • application service method,
  • repository method,
  • response mapping,
  • tests at service and HTTP levels.

Then delete one abstraction that did not help.

After your own attempt, use another reviewer or an AI tool as a second pass. Accept a suggestion only when you can explain why it preserves the lesson design.

Ask AI to "clean up the architecture" of an Axum route. Review whether it:

  • creates more layers than needed,
  • keeps dependency direction correct,
  • hides behavior in generic services,
  • makes tests easier or harder.

You can move on when these statements are true.

  • Does the domain depend on frameworks?
  • Is the service method named after a use case?
  • Can a worker reuse this service?
  • Are transaction boundaries visible?
  • Are errors mapped at the edge?
  • Did any new abstraction make the code harder to inspect?

Curated Resources

Next Step

Continue to Application Framework and Scaffolding Lab.