TL;DR
After building three SaaS products, we've standardized on Clean Architecture across all our projects at Atbion. Not because it's trendy — because it solved real problems: API changes breaking UI code, untestable business logic tangled with framework details, and new engineers needing weeks to understand where things live.
Introduction
Every engineering team eventually faces the same question: how do we structure our code so it doesn't become unmaintainable in 12 months? When we started building our first SaaS product at Atbion in mid-2024, we made the classic mistake. Business logic lived in React components. API calls were scattered across pages. The breaking point came when our backend team changed the API response format for user profiles. What should have been a 30-minute change turned into a two-day refactoring nightmare. That's when we adopted Clean Architecture.
What Clean Architecture Actually Means
Presentation
React Components, Pages, Hooks
Application
Use Cases, Business Logic
Infrastructure
API Clients, Datasources, Mappers
Domain
Entities, Repository Interfaces
The rule is simple: dependencies always point inward, toward the Domain. A React component can call a Use Case. A Use Case depends on a Repository interface defined in Domain. Infrastructure implements that interface. The inner layers never know about the outer layers.
Layer by Layer: How It Works
Domain — The Core
The domain layer defines what our application does, without knowing how. Entities are pure TypeScript interfaces. Repository interfaces define contracts without specifying implementation details. No fetch, no axios, no HTTP concepts.
TypeScriptexport interface Job {
id: string;
title: string;
hourlyRate: number;
status: 'open' | 'in_progress' | 'completed';
}
export interface JobsRepository {
getJobsList(filters?: JobFilters): Promise<PaginatedResponse<Job>>;
getJobById(id: string): Promise<Job>;
}
Infrastructure — The How
This is where real-world complexity lives. APIs return snake_case, endpoints change, auth tokens expire. The infrastructure layer absorbs all of this so the rest of the codebase doesn't care. Models mirror the API, mappers transform DTOs into domain entities, datasources handle HTTP, and repository implementations wire it all together.
TypeScriptexport function mapJob(response: JobResponse): Job {
return {
id: response.id,
title: response.title,
hourlyRate: response.hourly_rate, // snake → camel
status: response.status as Job['status'],
};
}
export class JobsRepositoryImpl implements JobsRepository {
constructor(private datasource: JobsDatasource) {}
}
Application — Business Logic
Use Cases contain business rules that don't belong in UI components. They are completely testable without any framework, HTTP mock, or browser environment.
TypeScriptexport class SearchJobsUseCase {
constructor(private repository: JobsRepository) {}
async execute(params: SearchJobsParams): Promise<PaginatedResponse<Job>> {
const filters = this.buildFilters(params);
this.validateFilters(filters);
return this.repository.getJobsList(filters);
}
}
Presentation — Server Actions as Controllers
In Next.js, Server Actions serve as the thin adapter between the framework and our use cases. They handle authentication, parse form data, call the factory to instantiate dependencies, execute the use case, and revalidate the cache. The Server Action never contains business logic — it's a controller in Clean Architecture terms.
TypeScript'use server'
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { makeSearchJobsUseCase } from '@/infrastructure/factories';
export async function searchJobsAction(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const searchJobs = makeSearchJobsUseCase(); // factory wires all deps
const query = formData.get('query') as string;
const filters = { query, status: formData.get('status') as string };
const results = await searchJobs.execute(filters);
revalidatePath('/jobs');
return results;
}
The factory pattern is the key insight. Instead of manually wiring new JobsDatasource() → new JobsRepositoryImpl(datasource) → new SearchJobsUseCase(repository) in every Server Action, the factory encapsulates that chain into a single function call. Clean isolation per request, zero boilerplate in the action itself.
What Changes When the API Changes
This is the real test of any architecture. Here's what happened when our backend team made breaking changes:
Renamed API field
2 files
Updated the DTO model and the mapper. Zero components, zero use cases changed.
Added pagination metadata
3 files
Updated DTO, domain entity, and mapper. Use cases and components untouched.
Switched REST to GraphQL
2 files
Replaced datasource and DTO. Mappers, use cases, and UI all unchanged.
When we migrated our logistics platform from REST to GraphQL, the change was entirely contained in the infrastructure layer — a new datasource and updated DTOs. The 14 use cases, 8 React components, and 47 unit tests all continued to work without a single modification. Our client didn't even know we changed the transport protocol until we told them at the next sprint review.
Measurable Results
After 12 months of using Clean Architecture across three SaaS products:
These metrics come from three SaaS products we built and maintained over 12 months: a fintech payment platform, a logistics management system, and a multi-tenant HR tool. We tracked every API change, every bug report, and every new feature request to measure how architecture decisions affected development velocity.
The most dramatic improvement was in API change impact. Before Clean Architecture, renaming a single API field touched an average of 23 files across components, hooks, utilities, and tests. After layered separation, the same change touched 2 files: the DTO model and the mapper. Everything upstream — use cases, components, tests — remained untouched because they depend on domain entities, not API contracts.
Test coverage jumped from 34% to 81% — not because we wrote more tests, but because the code became testable. When business logic lives in pure TypeScript use cases with injected dependencies, testing is straightforward: create a mock repository, inject it, assert outputs. No browser environment, no API mocking, no framework bootstrapping.
-85%
API change impact
-92%
Data mapping bugs
+47pp
Test coverage
-75%
Time to add entity
-78%
Time to understand feature
Common Mistakes We Made
Over-abstracting too early
We created abstract base classes for everything. Most entities had different enough APIs that the base classes added complexity without reducing code. Now we write concrete classes first. Our BaseRepository<T> had 14 abstract methods, and every implementation overrode at least 6 of them with completely different logic. The abstraction was hiding complexity, not reducing it.
Putting business logic in mappers
Mappers should be dumb — pure data transformation. If a mapper needs an if statement that isn't about data format, the logic should move up to the use case layer.
Creating too many use cases
We initially created a use case for every action. Now we use one use case per meaningful business operation. SearchJobsUseCase handles searching, filtering, and sorting — one business action.
Forgetting the factory pattern
We manually wired dependencies in every server action — three lines of boilerplate before doing anything. The factory pattern reduced it to one line with clean isolation per request. Before factories, adding a new use case meant updating 8-12 server actions that depended on it. With the factory, you update one function and every consumer gets the new dependency graph automatically.
Conclusion
Clean Architecture isn't about drawing pretty layer diagrams. It's about answering one question: when something changes, how many things break? With our implementation, the answer is almost always "just the layer that needs to change." The initial cost is real — more files, more structure. But in a SaaS product you'll maintain for years, that cost pays for itself within the first quarter. We didn't choose Clean Architecture because Uncle Bob told us to. We chose it because shipping broken code to production at 2 AM was the kind of pain we never wanted to feel again.
There's an unexpected bonus we discovered in 2026: Clean Architecture makes AI-assisted development dramatically more effective. When Claude Code understands your layer conventions — entities here, repositories there, use cases in between — it generates code that fits perfectly into the existing structure. Our CLAUDE.md file describes the architecture once, and every AI-generated module follows the pattern. The result: AI generates 9 files for a new entity, all in the right places, all following the right conventions. Clean Architecture gave us predictable code. AI made that predictability scale.