Hazem Azzam

All posts
Writing

Clean Architecture in Real Projects: A Practical Guide

A practical walkthrough of Clean Architecture and dependency injection in real codebases. Learn how to keep business rules independent of frameworks, wire ports and adapters cleanly, and evolve a codebase without rewrites.

6 min read
clean-architecturedependency-injectionsoftware-designfrontendbackendmaintainability

Clean Architecture in Real Projects: A Practical Guide

Clean Architecture sounds great in theory: isolate business rules, keep frameworks at the edges, and make code easier to test and maintain. But when deadlines are tight, teams are small, and products keep changing, many projects drift into tightly coupled code.

This guide explains how to apply Clean Architecture in a practical way without overengineering. The focus is not strict dogma, but decisions that keep your codebase adaptable over time.

What Clean Architecture Actually Solves

At its core, Clean Architecture protects business rules from change caused by frameworks, UI libraries, databases, and infrastructure details.

In most real projects, these changes happen often:

  • API contracts evolve
  • frontend frameworks update
  • auth providers change
  • database strategies shift
  • deployment targets move from one platform to another

If business logic is mixed with these details, every change ripples through your codebase. Clean Architecture reduces that ripple effect.

The Dependency Rule (Most Important Idea)

Dependencies should point inward.

  • Outer layers can depend on inner layers.
  • Inner layers must not depend on outer layers.

That means:

  • Domain rules do not import React, Next.js, Django, ORM models, Axios, or fetch clients.
  • Use cases do not know whether data comes from PostgreSQL, local cache, or an HTTP API.

This single rule is more important than folder names.

A Practical Layer Breakdown

You do not need 20 folders to get value. Start with four clear responsibilities:

  1. Domain

    • Entities, value objects, domain rules
    • Pure logic, no framework imports
  2. Application (Use Cases)

    • Orchestrates domain logic to perform user/business actions
    • Depends on abstractions (interfaces), not concrete adapters
  3. Interface Adapters

    • Controllers, presenters, DTO mappers, repository implementations
    • Converts between external formats and internal models
  4. Infrastructure / Frameworks

    • HTTP clients, database drivers, UI framework glue, external SDKs

The exact names vary by project. The key is keeping boundaries explicit.

Example: Updating Personal Info

Suppose your dashboard has a “Personal Info” form that updates profile data.

A common coupled approach puts everything in one component:

  • form validation
  • API calls
  • response mapping
  • error interpretation
  • domain rules

A cleaner approach:

  • Use case: UpdatePersonalInfo
  • Repository interface: PersonalInfoRepository
  • Adapter implementation: ApiPersonalInfoRepository
  • UI component: collects input, calls use case, renders states

The UI does not know transport details. The use case does not know Axios or fetch. Mapping and HTTP concerns stay in adapters.

Frontend Structure (React / Next.js)

A practical structure might look like this:

src/
  app/
    personal-info/
      page.tsx
      _components/
      _hooks/
  features/personal-info/
    domain/
      PersonalInfo.ts
      PersonalInfoRules.ts
    application/
      UpdatePersonalInfo.ts
      GetPersonalInfo.ts
      ports/
        PersonalInfoRepository.ts
    infrastructure/
      ApiPersonalInfoRepository.ts
      PersonalInfoMapper.ts

Why this helps

  • You can unit test UpdatePersonalInfo without rendering UI.
  • You can swap API shape changes in PersonalInfoMapper only.
  • You can move from REST to GraphQL with minimal impact.

Backend Structure (Django / DRF)

In many Django projects, views become heavy and hold validation, orchestration, and persistence logic together.

A cleaner split:

  • View: HTTP concerns only (request/response, status codes)
  • Serializer/DTO mapper: transport validation and mapping
  • Use case/service: business workflow
  • Repository/gateway: ORM queries and persistence details

Even if you keep DRF serializers, avoid putting business orchestration in views. Keep views thin.

DTO Mapping: The Boundary Protector

DTO mapping is where many teams cut corners. Don’t.

External contracts change frequently. Your domain model should not.

For example:

  • API returns current_company as numeric id
  • UI select expects string values
  • Domain might represent it as CompanyId value object

A mapper is the correct place to normalize this. Without mapping boundaries, small schema shifts leak into every layer.

Dependency Injection: How the Wiring Works

Once you have ports (repository interfaces) and adapters (their concrete implementations), there's still the question of who decides which adapter the use case uses. That decision is dependency injection.

The use case shouldn't know. The UI shouldn't decide either. Both should receive their collaborators as inputs and stay agnostic about how they're constructed.

What it looks like in practice

A use case takes its dependencies as constructor arguments rather than importing them directly:

// application/UpdatePersonalInfo.ts
export class UpdatePersonalInfo {
  constructor(private readonly repo: PersonalInfoRepository) {}

  async execute(input: PersonalInfoInput): Promise<PersonalInfo> {
    PersonalInfoRules.validate(input);
    return this.repo.save(input);
  }
}

The use case knows about the interface PersonalInfoRepository, not the adapter that implements it. Wiring happens in one composition point — usually a small _di.ts file per feature, or a single root container.

// _di.ts
import { ApiPersonalInfoRepository } from "./infrastructure/ApiPersonalInfoRepository";
import { UpdatePersonalInfo } from "./application/UpdatePersonalInfo";

const personalInfoRepository = new ApiPersonalInfoRepository();
export const updatePersonalInfo = new UpdatePersonalInfo(personalInfoRepository);

The UI imports updatePersonalInfo and calls .execute(...). It never sees the adapter. Tests instantiate the use case with a fake repository. Production swaps the API adapter for, say, an offline cache adapter without touching the use case or the UI.

DI on the backend (Django)

In a Django service, the easiest place for the composition root is the view (or a thin factory imported by views):

# api/_di.py
from .infrastructure.repositories import ApiPersonalInfoRepository
from .application.use_cases import UpdatePersonalInfoUseCase

def update_personal_info_use_case():
    return UpdatePersonalInfoUseCase(repo=ApiPersonalInfoRepository())
# api/views.py
class PersonalInfoView(APIView):
    def put(self, request):
        use_case = update_personal_info_use_case()
        result = use_case.execute(request.data)
        return Response(PersonalInfoSerializer(result).data)

The view is the only place that knows which adapter exists. Swap ApiPersonalInfoRepository for CachedPersonalInfoRepository in _di.py and every consumer keeps working.

Rules of thumb

  • Construct, don't import. Use cases receive collaborators; they don't reach out for them.
  • One composition root per bounded context. A small _di file per feature scales better than one global container that grows forever.
  • Don't reach for a DI framework on day one. Plain constructors and one wiring file go a long way. Bring in InversifyJS / dependency-injector / Awilix when the manual wiring genuinely starts to hurt.
  • The composition root is the only place that depends on every layer. That's by design — it's the seam between abstract and concrete. Keep it small and obvious.

What you get back

  • Unit tests get cheap. new UpdatePersonalInfo(new FakeRepo()) replaces a mock-heavy integration setup.
  • Adapter swaps stop being scary. Moving from REST to GraphQL, or from one auth provider to another, is a one-file change in the composition root.
  • Domain stays innocent. It can't accidentally depend on Axios because it never imports it.

DI is what keeps the dependency rule honest. Without it, the rule is just a folder convention.

Testing Strategy by Layer

Clean Architecture improves testing only if you test the right things in the right place.

Domain tests

  • Fast, pure unit tests
  • No mocks for framework code
  • Validate invariants and rules

Use case tests

  • Mock repository interfaces
  • Verify orchestration and decision logic
  • Cover success and failure branches

Adapter/integration tests

  • Ensure HTTP/DB mapping correctness
  • Validate edge cases with real transport format

UI tests

  • Focus on rendering and interaction behavior
  • Avoid duplicating business tests already covered in use cases

Common Mistakes (and Better Alternatives)

Mistake 1: Creating too many abstractions too early

If a dependency is stable and unlikely to change, don’t add extra layers yet. Start simple and extract boundaries where volatility is high.

Mistake 2: Calling everything “service”

Name by responsibility:

  • use cases for business actions
  • repositories for persistence ports
  • mappers for translation

Clear naming reduces architectural drift.

Mistake 3: Putting framework types in domain

If domain entities depend on framework types, your core is no longer independent.

Mistake 4: Treating folder layout as architecture

Architecture is behavior and dependency direction, not directory aesthetics.

Mistake 5: Wiring dependencies inline everywhere

Calling new ApiPersonalInfoRepository() inside a component (or inside the use case itself) defeats the boundary. The component now depends on a concrete adapter, and tests have to reach into module mocks to swap it. Construct dependencies in one composition root and pass them in.

Incremental Adoption Plan

You do not need a full rewrite. Use this sequence:

  1. Identify one unstable feature (frequent API or requirement changes).
  2. Extract one use case from UI or view logic.
  3. Add one repository interface and one adapter implementation.
  4. Introduce explicit request/response mapping.
  5. Add tests for domain and use case.
  6. Repeat feature by feature.

This approach gives immediate value with low migration risk.

Decision Checklist Before Adding a Layer

Ask these questions:

  • Will this dependency likely change in the next 6–12 months?
  • Do we need to test this logic without framework runtime?
  • Is this logic business-critical or just presentation detail?
  • Is coupling here slowing delivery already?

If most answers are yes, a boundary is justified.

Performance and Developer Experience

A common concern is overhead. In practice, the cost is usually small compared with long-term maintenance gains.

Keep DX strong by:

  • using simple conventions per feature
  • generating boilerplate only when needed
  • avoiding unnecessary indirection
  • documenting one end-to-end example in the repo

The goal is clarity, not ceremony.

When Clean Architecture Is Not Necessary

Not every codebase needs full layering.

For very small prototypes, a lightweight modular approach may be enough. But as soon as multiple contributors, evolving requirements, or long-lived maintenance enters the picture, explicit boundaries pay off quickly.

Final Thoughts

Clean Architecture is not about perfect purity. It is about protecting the parts of your system that matter most: business rules and core decisions.

Use it pragmatically:

  • keep dependencies pointing inward
  • isolate framework and transport concerns
  • introduce boundaries where change pressure is real
  • evolve incrementally instead of rewriting everything

That balance gives you the biggest advantage: a codebase that can change without breaking your team’s speed.


Rate this post

All fields are optional. Just stars is fine.

No ratings yet