Clean Architecture
ARCHITECTURE

Why We Chose Clean Architecture for Our SaaS Products

Yadisnel Galvez Velázquez · February 25, 2026 · 14 min read

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.

TypeScript
export 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.

TypeScript
export 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.

TypeScript
export 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

01

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.

02

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.

03

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.

04

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.

Building a SaaS product that needs to scale?

Let's talk about how clean architecture can save your team from refactoring nightmares and help you ship faster.

Let's Talk
Yadisnel Galvez

Yadisnel Galvez Velázquez

Founder & CEO at Atbion

Software engineer specialized in distributed systems and cloud architecture. Building Atbion to prove that clean, well-architected code is the foundation of great software products.