Beginner's Guide to Clean Architecture in Backend Development


Build Maintainable Backends

Clean Architecture separates domain logic, infrastructure, and interfaces. This reduces coupling, improves testability, and supports long-term scalability. Developers use layers such as entities, use cases, controllers, and gateways.

Clean, modular architecture ensures your backend stays maintainable as features grow.

Beginner's Guide to Clean Architecture in Backend Development

A practical, example-driven introduction to building maintainable backend systems using Clean Architecture principles.


Who this guide is for

  • Backend developers who know the basics (HTTP, databases, routing).

  • Engineers who want systems that are easy to test, change, and reason about.

  • Teams preparing to scale codebases and reduce technical debt.


What you'll learn

  1. Core principles of Clean Architecture.

  2. How to structure a backend codebase (layers and boundaries).

  3. Roles of entities, use-cases/interactors, interfaces/gateways, and frameworks.

  4. Practical folder layout and simple code examples.

  5. Testing strategy and deployment considerations.


1. Core ideas (short & practical)

  • Separation of concerns: keep business rules (core logic) independent from frameworks, databases, and UI.

  • Dependency rule: source code dependencies point inward — outer layers can depend on inner layers, but not vice-versa.

  • Boundaries by intent: define clear interfaces between business logic and infrastructure.

  • Testability: you should be able to run the core logic in tests without hitting network, DB, or external services.


2. Layers and responsibilities (simple picture)

From inner to outer:

  1. Entities (Domain Models)enterprise-wide business rules. Plain objects, validations, and domain logic.

  2. Use Cases / Interactors (Application Business Rules) — application-specific rules: orchestrate entities to accomplish tasks.

  3. Interface Adapters (Ports & Adapters) — translate data between the use-cases and the outer world (controllers, presenters, repositories).

  4. Frameworks & Drivers (Infrastructure)web frameworks, databases, messaging, UI, external APIs.

The inner layers should not know about the outer layers. They express their needs via interfaces.


3. Common folder structure (opinionated, practical)

src/
├─ domain/
│  ├─ models/
│  └─ services/          # domain-only helpers
├─ usecases/
│  ├─ createUser.js
│  └─ getInvoice.js
├─ interfaces/
│  ├─ controllers/
│  ├─ presenters/
│  └─ repositories/      # interface definitions (ports)
├─ infrastructure/
│  ├─ http/              # express, fastapi handlers
│  ├─ db/                # concrete repos (adapters)
│  └─ email/             # concrete email sender
└─ main.js               # composition root / dependency wiring

Notes:

  • usecases talk to repositories via interfaces defined in interfaces/repositories (or as abstractions in the usecase module).

  • infrastructure implements those interfaces.

  • main.js (composition root) wires concrete implementations into use-cases and starts frameworks.


4. Example: Create User (conceptual)

Entities

  • User (fields, invariants like email format).

Use case

  • CreateUser(email, password) — validate, hash password, persist via UserRepository, send welcome email via EmailService.

Interfaces (ports)

  • UserRepository interface: save(user), findByEmail(email)

  • EmailService interface: sendWelcome(user)

Implementations (adapters)

  • PostgresUserRepository implements UserRepository and depends on a DB client.

  • SmtpEmailService implements EmailService and depends on SMTP config.

Why this helps

  • To test CreateUser you mock UserRepository and EmailService. No DB or SMTP required.

  • To switch DB later, only PostgresUserRepository changes — CreateUser remains untouched.


5. Sample pseudo-code (JavaScript style)

usecases/createUser.js (core — no framework imports)

class CreateUser {
  constructor(userRepo, emailService, passwordHasher) {
    this.userRepo = userRepo;
    this.emailService = emailService;
    this.passwordHasher = passwordHasher;
  }

  async execute({ email, password }) {
    if (await this.userRepo.findByEmail(email)) {
      throw new Error('Email already used');
    }
    const hashed = await this.passwordHasher.hash(password);
    const user = { id: generateId(), email, passwordHash: hashed };
    await this.userRepo.save(user);
    await this.emailService.sendWelcome(user);
    return user;
  }
}

module.exports = CreateUser;

infrastructure/db/postgresUserRepo.js

class PostgresUserRepo {
  constructor(dbClient) { this.db = dbClient; }
  async findByEmail(email) { /* query DB */ }
  async save(user) { /* insert */ }
}
module.exports = PostgresUserRepo;

main.js (composition root)

const dbClient = makeDbClient(config);
const userRepo = new PostgresUserRepo(dbClient);
const emailService = new SmtpEmailService(smtpConfig);
const passwordHasher = new BcryptHasher();
const createUser = new CreateUser(userRepo, emailService, passwordHasher);

// Wire into an HTTP controller / route
app.post('/users', async (req, res) => {
  try {
    const user = await createUser.execute(req.body);
    res.status(201).json({ id: user.id });
  } catch (err) { res.status(400).json({ error: err.message }); }
});

6. Dependency inversion & ports/adapters

  • Ports: interfaces the core uses (e.g., UserRepository, EmailSender).

  • Adapters: concrete implementations (e.g., Postgres adapter, SendGrid adapter).

Define ports close to the use case or in a dedicated interfaces package. Keep them minimal and focused on the core's needs.


7. Testing strategy

  • Unit tests: test use-cases with mocked ports. No DB, no network.

  • Integration tests: test adapters with real DB or test DB, but keep them separate and fewer in number.

  • End-to-end tests: test the whole system via HTTP/CLI where necessary.

Tips:

  • Make your use-case constructor accept dependencies so tests can inject mocks/stubs.

  • Use in-memory DBs or containers for integration tests.

  • Keep business logic out of controllers to avoid complex controller tests.


8. Practical tips & anti-patterns

Do

  • Keep business rules in the inner layers.

  • Keep thin controllers/adapters.

  • Keep wiring in a single composition root.

  • Favor small, focused interfaces.

Don't

  • Import framework code (e.g., ORM, HTTP framework) into your use-case modules.

  • Scatter environment config across modules — centralize in main.

  • Put SQL queries inside entities or use-cases — put them in repository adapters.


9. When to adopt Clean Architecture

  • Start early if you expect complexity and team growth.

  • For small toy projects, it can feel heavy — adapt the pattern proportionally.

  • For long-lived services or services where business rules change often, Clean Architecture pays off.


10. Next steps & learning resources (how to practice)

  1. Pick one endpoint in your app and refactor it into: domain, use-case, adapters, controller.

  2. Write unit tests for the use-case with mocked ports.

  3. Add another adapter (e.g., switch email provider) and notice you didn't change core logic.

  4. Read source materials: Uncle Bob's Clean Architecture, Hexagonal Architecture, Ports & Adapters.


Appendix: Quick reference checklist (for PRs)

  • Does the change avoid importing framework code into domain/use-case files?

  • Are dependencies injected at construction time (not hard-coded)?

  • Are tests possible without an external DB or network service?

  • Does the composition root wire concrete implementations in one place?


If you'd like, I can now:

  • Provide a language-specific example (Node.js + Express, Python + FastAPI, Java + Spring Boot).

  • Create a small starter repo layout with sample files.

  • Convert this guide into slides or a one-page cheat-sheet.

Tell me which option you want next.

Previous Post Next Post