Back to Blog
9 min read

Clean architecture in practice: lessons from a real-world SaaS rewrite

When a client came to us with a 6-year-old monolith that was costing $400k per year in developer slowdown, we redesigned it from scratch using clean architecture principles. Here's what actually worked — and what the textbooks don't tell you.

Clean architecture, hexagonal architecture, ports and adapters — these concepts have been discussed extensively in software engineering literature since Robert Martin's 2012 blog post and subsequent book. What gets discussed less is the friction, the trade-offs, and the pragmatic decisions you make when you are applying these patterns to a real codebase under commercial pressure. This post is about that.

The problem: a monolith at its limits

Our client — a B2B SaaS company in the legal-tech space — had a six-year-old Node.js application that had grown from a simple case management tool into a platform serving 3,400 law firms across Canada and the UK. The codebase was approximately 180,000 lines of JavaScript, with no clear separation between business logic, infrastructure concerns, and API routes. A typical feature request touched 8–12 files across the application, and any change to the database schema required updates in an average of 22 locations.

$400k
estimated annual cost of developer slowdown
22
average file locations touched per schema change
6 wks
average time for a new developer to first commit

The team had reached a point where engineers were spending more time understanding existing code than writing new code. Onboarding took six weeks before new developers felt confident making changes. The automated test suite covered 31% of the codebase, and running it took 44 minutes.

Our approach: strangler fig, not big bang

The instinct with a project like this is to rewrite everything from scratch. We strongly advised against it. Complete rewrites are notoriously risky — they take longer than estimated, lose institutional knowledge embedded in the existing code, and require maintaining two systems in parallel. Instead, we used the strangler fig pattern: gradually replacing the monolith's functionality with a new, clean-architecture implementation, module by module.

Key Decision

We introduced a thin routing layer at the API boundary that initially forwarded all requests to the legacy monolith. Over 14 months, we moved routes one at a time to the new implementation. The monolith continued serving production traffic throughout.

What the domain layer actually looked like

In the new architecture, the domain layer contains pure business logic with zero dependencies on frameworks, databases, or external services. A Case entity knows the rules of a legal case — status transitions, document requirements, deadline calculations — but knows nothing about how it is stored or how it is presented via the API.

// domain/entities/Case.ts
export class Case {
  private constructor(
    public readonly id: CaseId,
    public readonly client: ClientRef,
    private status: CaseStatus,
    private documents: Document[]
  ) {}

  static create(client: ClientRef): Case {
    return new Case(CaseId.generate(), client, CaseStatus.OPEN, []);
  }

  addDocument(doc: Document): void {
    if (this.status === CaseStatus.CLOSED) {
      throw new DomainError('Cannot add documents to a closed case');
    }
    this.documents.push(doc);
  }

  close(): void {
    if (!this.hasRequiredDocuments()) {
      throw new DomainError('Case cannot be closed without required documents');
    }
    this.status = CaseStatus.CLOSED;
  }
}

This is not novel — it is textbook domain-driven design. But the discipline of enforcing it consistently, refusing to let any infrastructure concern bleed into the domain layer, is harder to maintain than it looks when you have a team of twelve developers and commercial deadlines.

What the textbooks don't tell you

The boundary will be challenged constantly

Developers under pressure will find the shortest path to making something work. If the path of least resistance leads through the domain layer with a database import, someone will do it. The only way we maintained the boundary was through automated architecture fitness functions in CI — if a file in the domain layer imported from the infrastructure layer, the pipeline failed. Not a warning. A failure.

Domain events are worth the complexity

One of the best decisions we made was to model all state changes in the domain as explicit domain events: CaseOpened, DocumentAdded, CaseClosed. These events are emitted by the domain entities and handled by application-layer services that produce side effects (sending emails, triggering integrations, updating read models). It added complexity upfront but made the system vastly easier to test, extend, and debug in production.

The repository pattern needs query flexibility

The standard repository pattern — findById, save, delete — breaks down quickly when your UI needs complex filtered, paginated lists. We ended up with two patterns in parallel: rich repositories for domain operations, and dedicated read-model query services for list views. The read models bypass the domain layer entirely and query the database directly with optimised SQL. This is not architecturally pure, but it is pragmatic and performant.

Results after 14 months

  • Test coverage increased from 31% to 84%
  • CI pipeline time reduced from 44 minutes to 9 minutes
  • Average time to onboard a new developer reduced from 6 weeks to 10 days
  • Schema changes now touch an average of 3 files (down from 22)
  • Deployment frequency increased from 2× per week to 8× per week

"The new architecture feels like building with Lego instead of clay. Everything has a clear shape and a clear place. Onboarding our last two engineers took less than two weeks each."

If your codebase is showing these warning signs — slowing feature velocity, long onboarding times, high change amplification — we are happy to do a free 30-minute architecture review to discuss your options.

More from the blog

Cloud InfrastructureMay 15, 2026

Kubernetes cost optimisation: five patterns that cut our clients' bills by 40%

Container orchestration platforms are powerful and easy to over-provision. Here are the five techniques we apply to every K8s deployment.

Read more
Mobile DevelopmentApril 30, 2026

React Native in 2026: still the right choice for cross-platform?

After shipping 18 React Native apps and three Flutter projects, we share an honest assessment of when to choose each.

Read more
AI & Machine LearningMay 22, 2026

Why most AI projects fail — and what the successful ones do differently

After 40+ AI implementations, the patterns that separate projects that deliver ROI from those that stall at the POC stage.

Read more
WhatsApp