Back to Insights
clean architectureJuly 25, 20268 min read

Clean Architecture Boundaries for Real Applications

Clean architecture becomes practical when dependency direction, translation, transactions, and tests protect business policy without multiplying pass-through layers.

John Mather, Founder of Prime Axiom AI
John Mather

Founder, Prime Axiom AI

Published July 25, 2026

Application policies at the center with adapters and infrastructure around them
Clean boundaries point dependencies toward policy while translating edge concerns.

Clean Architecture Boundaries for Real Applications

Clean architecture is often drawn as circles: entities in the center, frameworks at the edge, dependencies pointing inward. Real applications are messier. They have authentication middleware, ORM transactions, queue handlers, generated clients, reporting queries, and deadlines. The useful question is not “which circle contains this file?” It is “which decisions should survive when a delivery mechanism or infrastructure product changes?”

This architecture deep dive follows one use case—approving an expense claim—from an HTTP request through policy, persistence, and event publication. Along the way it distinguishes boundaries that protect decisions from layers that merely rename data.

The dependency rule in executable form

Core policy must not import a web framework, ORM, cloud SDK, or message broker. Outer code may import inner contracts and translate to them.

mermaid
flowchart RL
  DB[(PostgreSQL)] --> Adapter[Claim Repository Adapter]
  HTTP[HTTP Controller] --> App[Approve Claim Use Case]
  Adapter --> Port[Claim Repository Port]
  Broker[Message Broker] --> EventAdapter[Event Publisher Adapter]
  EventAdapter --> EventPort[Event Publisher Port]
  App --> Domain[Expense Claim Domain]
  App --> Port
  App --> EventPort

The arrows demonstrate source-code dependency, not runtime call direction. The repository adapter calls the database at runtime, yet it depends on a contract owned inward. This distinction allows business policy to compile and run without database packages.

Microsoft's guide to common web application architectures discusses layered and clean architecture variants. Android's app architecture recommendations similarly emphasize separation of concerns, persistent models, and testable layers. Names differ; dependency control is the shared value.

Begin at the business decision

An expense claim can be approved only by an authorized manager, only while Pending, and claims above a threshold require a second approver. Put these rules in domain language:

ts
class ExpenseClaim {
  private status: ClaimStatus = 'pending';
  private approvals: Approval[] = [];

  approve(by: Approver, policy: ApprovalPolicy, at: Instant): ClaimDecision {
    if (this.status !== 'pending') return { kind: 'already_decided' };
    if (!by.canApprove(this.ownerId)) return { kind: 'unauthorized' };

    this.approvals.push({ approverId: by.id, at });
    if (policy.isSatisfiedBy(this.amount, this.approvals)) {
      this.status = 'approved';
      return { kind: 'approved' };
    }
    return { kind: 'additional_approval_required' };
  }
}

This code demonstrates policy independent of transport and storage. There are no status codes, ORM decorators, or queue topics. The return type exposes business outcomes the application layer can translate.

Do not make entities responsible for loading themselves, sending email, or opening transactions. Those are coordination concerns. Conversely, do not reduce the domain to setters while a service contains every invariant; behavior belongs near the state it protects.

The application boundary coordinates one intent

ts
interface ClaimRepository {
  get(id: ClaimId): Promise<Versioned<ExpenseClaim> | null>;
  save(claim: ExpenseClaim, expectedVersion: number): Promise<void>;
}

interface BusinessEventOutbox {
  append(events: readonly BusinessEvent[]): Promise<void>;
}

class ApproveExpenseClaim {
  constructor(
    private readonly claims: ClaimRepository,
    private readonly outbox: BusinessEventOutbox,
    private readonly authorization: ApprovalAuthorization,
    private readonly clock: Clock,
    private readonly transaction: TransactionBoundary,
  ) {}

  execute(command: ApproveClaimCommand): Promise<ClaimDecision> {
    return this.transaction.run(async () => {
      const stored = await this.claims.get(command.claimId);
      if (!stored) return { kind: 'not_found' };

      const approver = await this.authorization.forUser(command.actorId);
      const result = stored.value.approve(approver, approvalPolicy, this.clock.now());
      await this.claims.save(stored.value, stored.version);
      await this.outbox.append(stored.value.releaseEvents());
      return result;
    });
  }
}

This block demonstrates an application service that owns sequencing and transaction scope. It depends on ports describing required capabilities. The transaction abstraction is intentionally narrow. Passing an ORM session inward would couple every use case to infrastructure semantics.

A practical variation lets the repository expose a unit-of-work contract when aggregate and outbox writes must be atomic. The exact interface matters less than preserving one rule: transaction policy is explicit and infrastructure mechanics remain at the edge.

Translation is real work

Controllers should translate protocol input to commands and results to protocol output:

ts
async function approveClaimController(request: HttpRequest): Promise<HttpResponse> {
  const parsed = approveClaimSchema.safeParse(request.body);
  if (!parsed.success) return problem(400, 'Invalid request', parsed.error);

  const result = await approveClaim.execute({
    claimId: ClaimId.parse(request.params.id),
    actorId: request.authenticatedUser.id,
    comment: parsed.data.comment,
  });

  switch (result.kind) {
    case 'approved': return { status: 200, body: { status: 'approved' } };
    case 'additional_approval_required': return { status: 202, body: { status: 'pending' } };
    case 'not_found': return problem(404, 'Claim not found');
    case 'unauthorized': return problem(403, 'Approval not permitted');
    case 'already_decided': return problem(409, 'Claim is already decided');
  }
}

This code demonstrates deliberate translation. HTTP concepts stop at the controller. Domain outcomes do not know status codes. Exhaustive matching makes a new business outcome a compile-time task for delivery adapters.

Translation is not pointless mapping when it prevents outer schemas from defining the core model. An ORM row may contain nullable migration fields and database timestamps; a domain object should contain valid state. A vendor response may expose dozens of fields; an application result should expose the decision it needs.

Yet mapping every identical internal value through five classes is ceremony. Create a boundary where ownership, vocabulary, lifecycle, or stability changes. Within one boundary, share immutable values freely.

Persistence without an anemic domain

ts
class SqlClaimRepository implements ClaimRepository {
  constructor(private readonly txContext: TransactionContext) {}

  async get(id: ClaimId): Promise<Versioned<ExpenseClaim> | null> {
    const row = await this.txContext.current.query.claims.findFirst({
      where: (claim, op) => op.eq(claim.id, id.value),
      with: { approvals: true },
    });
    return row ? ClaimMapper.toDomain(row) : null;
  }

  async save(claim: ExpenseClaim, expectedVersion: number): Promise<void> {
    const changed = await updateWithVersion(
      this.txContext.current,
      ClaimMapper.toPersistence(claim),
      expectedVersion,
    );
    if (!changed) throw new ConcurrencyConflict();
  }
}

This block demonstrates an adapter translating rows and enforcing optimistic concurrency. The domain need not carry ORM decorators. Repository integration tests should verify mapping, transaction participation, and version conflicts against the real database.

For simple CRUD screens, using ORM projections directly in a read adapter can be sensible. Clean architecture does not require hydrating aggregates for a read-only table. Command and query paths can have different models without adopting a full CQRS platform.

The boundary inventory

BoundaryInward conceptOutward conceptEssential test
HTTPApproveClaimCommandJSON, auth, status codecontroller translation
PersistenceExpenseClaimrows, versions, SQL errorsrepository integration
MessagingBusinessEventtopic, headers, serializationschema/adapter contract
IdentityApprover capabilitytoken claims, directory rolesauthorization cases
TimeInstantsystem clockdeterministic policy

The table demonstrates that each adapter protects core vocabulary from an outer mechanism. If a “layer” has no translation, policy, or ownership change, ask whether it is necessary.

Testing by architectural risk

Domain tests cover state transitions and invariants with no framework. Application tests use contract-respecting fakes to cover sequencing, missing claims, authorization, and concurrency mapping. Adapter tests use real databases and protocol stubs. End-to-end tests cover a few critical paths, not every policy permutation.

ts
it('requires a second approval above the threshold', () => {
  const claim = pendingClaim({ amount: money('USD', '7500.00') });
  const firstManager = approver({ id: 'manager-1', level: 'manager' });

  const result = claim.approve(firstManager, twoPersonPolicy, instant);

  expect(result.kind).toBe('additional_approval_required');
  expect(claim.currentStatus()).toBe('pending');
});

This test demonstrates a high-value business rule without booting HTTP or a database. A separate repository contract test proves persistence can round-trip the resulting state.

Architectural rules can be enforced with dependency tests or lint boundaries: domain cannot import adapters; application cannot import HTTP. Such tests catch accidental erosion earlier than review. They should allow intentional shared value modules rather than forcing a rigid folder aesthetic.

Operations cross every circle

Trace context, deadlines, and cancellation must cross boundaries, but business signatures should not become bags of telemetry. An application execution context can carry actor, correlation, and deadline where those affect policy. Infrastructure instrumentation can wrap ports with decorators.

Emit metrics in two vocabularies. The use case records approved, additional approval required, conflict, and unauthorized. The adapter records SQL duration, pool saturation, and message publish attempts. A single generic “request failed” metric makes ownership unclear.

Deployments must account for persisted data and messages outliving code. Use backward-compatible schema expansion before contraction. Event consumers should tolerate added fields. A clean dependency graph does not eliminate migration sequencing.

Failure modes of “clean” implementations

Pass-through layers: Controller calls Service calls Manager calls Repository with the same DTO. Navigation grows, but no decision is protected.

ORM denial: teams build a homemade persistence framework to avoid acknowledging the real ORM. Adapters may use infrastructure fully; they simply stop it from leaking inward.

Universal repositories: a generic Repository<T> cannot express aggregate-specific queries, concurrency, or transaction promises. Prefer capability-oriented contracts.

Domain purity that ignores reality: a use case that cannot represent timeout, cancellation, or uncertain external outcomes is not clean; it is incomplete.

Premature enterprise structure: a small stable CRUD tool may need handlers and validation, not four projects and thirty interfaces.

Clean architecture optimizes the independence of important policy, not the number of layers.

Boundary review checklist

  • [ ] Can core policy run without a web server or database?
  • [ ] Do ports use the consumer's language?
  • [ ] Is transaction ownership visible?
  • [ ] Are transport, persistence, and vendor errors translated deliberately?
  • [ ] Do adapters have real integration tests?
  • [ ] Are read-only paths allowed to stay simple?
  • [ ] Are business and technical outcomes separately observable?
  • [ ] Does every layer protect a decision or ownership boundary?

The practical center

The expense claim example keeps approval policy central, coordination explicit, and implementation details replaceable. It does not pretend the database or HTTP is unimportant; it contains their influence. The result can change frameworks without rewriting approval rules, and can change approval policy without editing SQL controllers.

Use the dependency rule to protect what is valuable and stable. Translate where vocabularies, ownership, or lifecycle differ. Test policies quickly and adapters realistically. Remove layers that only forward data. That is clean architecture for a real application: not perfect circles, but disciplined boundaries with operational truth. Continue with Domain-Driven Design: Practical Boundaries, Modular Monolith, and /resources.

References

Apply the idea

Turn technical clarity into a working system.

Talk with Prime Axiom about architecture, AI automation, integrations, and the operational workflow behind your next build.