Most codebases don't fail because of a bad framework choice. They fail because every change touches everything. Clean architecture, combined with end-to-end type safety, is how we keep a product cheap to change on year three the way it was on week three.

When a system is young, everything is easy. The codebase fits in your head, and any change is a five-minute job. The real test of an architecture is what happens at scale: more features, more engineers, more edge cases. A good architecture keeps the cost of change roughly flat as the system grows. A bad one lets that cost creep upward until shipping anything feels dangerous.

The one rule that matters: dependencies point inward

Clean architecture is often drawn as concentric rings, but the whole idea reduces to a single rule: your business logic must not depend on your infrastructure. The database, the HTTP framework, the third-party APIs, the UI. These are details that plug into your core, not things your core reaches out to.

In practice, we organize code into three conceptual layers:

  • Domain. Pure business rules and types. No imports from frameworks, no database, no HTTP. Just the logic that would still be true if you swapped every tool.
  • Application. Use cases that orchestrate the domain. They depend on interfaces (ports), never concrete implementations.
  • Infrastructure. Adapters that implement those interfaces: Prisma repositories, Express controllers, API clients. This layer depends inward, and nothing inward depends on it.
// domain: knows nothing about the database
export interface StudentRepository {
  findById(id: StudentId): Promise<Student | null>;
  save(student: Student): Promise<void>;
}

// application: depends on the interface, not Prisma
export async function enrollStudent(
  repo: StudentRepository,
  input: EnrollInput,
): Promise<Result<Enrollment, EnrollError>> {
  // pure orchestration of domain rules
}

// infrastructure: the only place Prisma is imported
export class PrismaStudentRepository implements StudentRepository {
  async findById(id: StudentId) { /* ... */ }
}

The payoff is testability and change-tolerance. You can unit-test enrollStudent with an in-memory repository and no database at all. And when you migrate from one database to another, you write a new adapter while the domain and application layers don't change a line.

Make illegal states unrepresentable

Type safety isn't about adding annotations. It's about designing types so that bad states cannot compile. This is where TypeScript, used well, becomes an architectural tool rather than a linter.

  • Use discriminated unions instead of boolean flags. A payment that is { status: 'pending' } or { status: 'settled'; settledAt: Date } makes it impossible to have a "settled" payment with no settlement date.
  • Brand your identifiers. A StudentId and a TeacherId are both strings at runtime, but branding them as distinct types stops you from ever passing one where the other is expected.
  • Return results, don't throw for expected failures. A Result<T, E> type forces callers to handle the failure path, so error handling is checked by the compiler rather than remembered by the developer.
The best bug is the one that never compiles. Every illegal state you make unrepresentable is a class of production incident you have permanently deleted.

Keep types flowing end to end

A boundary is where types usually go to die: the HTTP layer, the database, the client-server gap. Modern TypeScript tooling lets you keep a single source of truth across all of them.

  • Validate at the edges with schemas. A schema library like Zod parses untrusted input once at the boundary and produces a fully-typed value for everything downstream. Validation and types stay in sync because they are the same declaration.
  • Infer, don't duplicate. Derive your database types, API request and response types and form types from one schema wherever possible, so a field change ripples through the type system instead of silently drifting.
  • Type the client-server contract. Whether through generated clients or a typed RPC layer, the frontend should get a compile error the moment a backend response shape changes.

Test the seams, not the plumbing

Clean boundaries make testing cheap, which is the point. We aim for a pyramid: many fast unit tests around the domain and use cases (no I/O), a focused set of integration tests around each adapter (real database, real HTTP), and a thin layer of end-to-end tests for the critical user journeys. When the domain is pure, most of your logic is covered by tests that run in milliseconds, so the suite stays fast enough that people actually run it.

Where to draw the line

Clean architecture can be over-applied. A tiny CRUD service doesn't need three layers and a dozen interfaces. The discipline scales with the stakes: apply it fully where the domain is rich and long-lived, and keep it light where the code is simple and disposable. The goal is not architectural purity. It's a codebase that a new engineer can change safely in their first week.

Key takeaways

  • One rule: dependencies point inward, so business logic never depends on infrastructure.
  • Split into domain, application and infrastructure layers, connected by interfaces.
  • Design types so illegal states cannot compile (unions, branding, results).
  • Keep a single source of truth for types from database to client.
  • Test the domain fast and cheap; reserve integration and E2E for the seams.
  • Apply the discipline in proportion to the stakes: pragmatism over purity.

Architecture is a bet on the future: you pay a little structure now to keep change cheap later. Done with type safety, that bet pays off every single sprint.