Givepo
Blog

Clean Architecture and SOLID Principles: Writing Software That Stays Maintainable

Why codebases slow down as they grow, and how clean architecture, the dependency rule and the SOLID principles keep business logic testable and easy to change, with a complete TypeScript example.

Givepo Team
7 min read
Illustration of concentric architecture layers with arrows pointing inward to the core, beside a SOLID principles checklistSoftware Engineering

Most software does not fail because it was impossible to build. It fails because after a year or two every change takes longer than the last, small fixes break unrelated features, and nobody dares touch the core. Clean architecture and the SOLID principles are a set of ideas for preventing exactly that, by keeping the parts of a system that change for different reasons apart from each other.

Why codebases decay

Decay almost always comes from coupling: business rules that know about HTTP requests, database rows and UI components. When pricing logic lives inside a controller that also parses JSON and writes SQL, you can’t test the pricing without a web server and a database. You can’t switch the database without touching the pricing, and you can’t reuse the pricing in a background job at all.

Good architecture is about drawing boundaries so that the most valuable code, your business rules, depends on as little as possible.

The dependency rule

Clean architecture, as described by Robert C. Martin, arranges code in concentric layers:

  1. Entities in the centre: core business objects and rules, such as an order and how its total is calculated.
  2. Use cases around them: application-specific workflows, such as placing an order or issuing a refund.
  3. Interface adapters: controllers, presenters and repositories that convert data between the use cases and the outside world.
  4. Frameworks and drivers on the outside: the web framework, the database, the UI, third-party services.

The single rule that makes it work: source code dependencies point inward only. The inner layers know nothing about the outer ones. A use case never imports the database client. Instead it declares an interface describing what it needs, a port, and an outer layer provides the implementation, an adapter. This is why the style is also called ports and adapters, or hexagonal architecture.

A folder structure that makes the layers visible might look like this:

Text
src/
  domain/            entities and value objects; no framework imports
    order.ts
  application/       use cases and the ports they need
    ports.ts
    place-order.ts
  infrastructure/    adapters: database, email, payment provider
    postgres-order-repository.ts
    email-notifier.ts
  interface/         HTTP handlers, CLI commands, UI
    http/orders-controller.ts
  main.ts            composition root: wires adapters into use cases

A complete example in TypeScript

Start with the entity. It contains pure business rules and imports nothing from frameworks:

TypeScript
// domain/order.ts
export type OrderLine = { sku: string; quantity: number; unitPriceCents: number }

export class Order {
  private constructor(
    readonly id: string,
    readonly customerId: string,
    readonly lines: readonly OrderLine[]
  ) {}

  static create(id: string, customerId: string, lines: OrderLine[]): Order {
    if (lines.length === 0) throw new Error('An order needs at least one line')
    if (lines.some((line) => line.quantity <= 0)) throw new Error('Quantities must be positive')
    return new Order(id, customerId, lines)
  }

  get totalCents(): number {
    return this.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0)
  }
}

The use case describes the workflow and declares the ports it depends on. Note that the interfaces are owned by the application layer, not by the database code:

TypeScript
// application/ports.ts
import type { Order } from '../domain/order'

export interface OrderRepository {
  save(order: Order): Promise<void>
}

export interface Notifier {
  orderPlaced(order: Order): Promise<void>
}

// application/place-order.ts
import { Order, type OrderLine } from '../domain/order'
import type { Notifier, OrderRepository } from './ports'

export class PlaceOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly notifier: Notifier
  ) {}

  async execute(input: { customerId: string; lines: OrderLine[] }) {
    const order = Order.create(crypto.randomUUID(), input.customerId, input.lines)
    await this.orders.save(order)
    await this.notifier.orderPlaced(order)
    return { orderId: order.id, totalCents: order.totalCents }
  }
}

An adapter in the outer layer implements the port with real technology, and a composition root wires everything together at startup:

TypeScript
// infrastructure/postgres-order-repository.ts
import type { Pool } from 'pg'
import type { OrderRepository } from '../application/ports'
import type { Order } from '../domain/order'

export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly db: Pool) {}

  async save(order: Order) {
    await this.db.query('INSERT INTO orders (id, customer_id, total_cents) VALUES ($1, $2, $3)', [order.id, order.customerId, order.totalCents])
  }
}

// main.ts
const placeOrder = new PlaceOrder(new PostgresOrderRepository(pool), new EmailNotifier(mailer))

The payoff shows up in testing. The use case can be tested in milliseconds with simple in-memory fakes, with no database, no mail server and no mocking library:

TypeScript
import { expect, test } from 'vitest'
import { PlaceOrder } from './place-order'
import type { Order } from '../domain/order'

test('places an order and notifies the customer', async () => {
  const saved: Order[] = []
  const notified: Order[] = []
  const placeOrder = new PlaceOrder({ save: async (order) => void saved.push(order) }, { orderPlaced: async (order) => void notified.push(order) })

  const result = await placeOrder.execute({
    customerId: 'customer-1',
    lines: [{ sku: 'TS-1042', quantity: 2, unitPriceCents: 1500 }],
  })

  expect(result.totalCents).toBe(3000)
  expect(saved).toHaveLength(1)
  expect(notified).toHaveLength(1)
})

The SOLID principles, one at a time

SOLID is a set of five object-oriented design principles that support the same goal at the level of individual classes and modules.

S: Single Responsibility Principle

A module should have one reason to change, meaning it should answer to one group of people or one concern. An invoice class that calculates tax, renders a PDF and sends email will change whenever finance, design or IT change their minds. Split it into a calculator, a renderer and a sender.

O: Open/Closed Principle

Code should be open for extension but closed for modification: you add behaviour by adding new code, not by editing tested code. A common technique is the strategy pattern:

TypeScript
interface DiscountRule {
  appliesTo(order: Order): boolean
  amountCents(order: Order): number
}

const rules: DiscountRule[] = [new LoyaltyDiscount(), new BulkOrderDiscount()]

export const discountCents = (order: Order) => rules.filter((rule) => rule.appliesTo(order)).reduce((sum, rule) => sum + rule.amountCents(order), 0)

// A new promotion is a new class in the list, not another branch in a growing if/else

L: Liskov Substitution Principle

Any implementation of an interface must be usable wherever the interface is expected without surprising the caller. A ReadOnlyOrderRepository that implements OrderRepository but throws on save() breaks this. Callers that rely on the contract will fail at runtime. If something cannot honour the whole contract, it needs a different, smaller interface.

I: Interface Segregation Principle

Clients should not depend on methods they don’t use. Prefer several small, focused interfaces, such as OrderReader and OrderWriter, over one large interface with twenty methods. Small interfaces are easier to implement, easier to fake in tests and easier to change.

D: Dependency Inversion Principle

High-level policy should depend on abstractions, not on concrete details. The PlaceOrder use case above depends on the OrderRepository interface, not on PostgreSQL, so the database can change without the business logic noticing. This principle is the engine of the dependency rule.

Design patterns worth knowing

  • Repository: hides how entities are stored behind a collection-like interface.
  • Strategy: swaps interchangeable algorithms, such as pricing, shipping or discount rules, behind one interface.
  • Adapter: wraps a third-party API in an interface that your application defines.
  • Factory: centralizes complex object creation and validation, as Order.create does.
  • Observer and domain events: let other parts of the system react to "order placed" without the order knowing who is listening.
  • Decorator: adds caching, logging or retries around an existing implementation without changing it.

When not to over-engineer

Clean architecture is a tool, not a religion. A small CRUD app with little business logic does not need four layers and a dozen interfaces. The ceremony would cost more than it saves. Apply the ideas in proportion to the complexity and expected lifetime of the code:

  • Always keep business rules out of controllers and UI components, even in small projects. It costs almost nothing.
  • Introduce ports and adapters where you have real reasons: external services you may replace, logic that needs fast tests, or several entry points such as API, jobs and CLI.
  • Don’t create an interface that has exactly one implementation and no test fake. Add it when the second need appears (you aren’t gonna need it, or YAGNI).
  • Refactor towards the architecture incrementally, one module at a time, guided by where change actually hurts.

Key takeaways

  • Maintainability comes from managing dependencies: keep business rules independent of frameworks and infrastructure.
  • Dependencies point inward. Use cases declare ports and outer layers provide adapters.
  • SOLID applies the same thinking to classes and modules: one responsibility, extension over modification, honest contracts, small interfaces, and dependence on abstractions.
  • Match the amount of architecture to the complexity of the problem.

Conclusion

Clean architecture is ultimately about protecting your most valuable code, the rules that make your business work, from the churn of frameworks, databases and delivery mechanisms. Keep dependencies pointing inward, keep modules focused, and test business logic without infrastructure. Your codebase will then stay fast to change in its fifth year as well as its first.

Further reading

  • #Architecture
  • #Clean Architecture
  • #Design Patterns
  • #SOLID
  • #TypeScript
Written by
Givepo Team

Givepo is an IT agency building websites, mobile apps and custom software that move your business forward.

Share this article

Free 30-minute consultation

Talk through your idea, scope and budget with our team.

Book a consultation

More articles

Blog
Backend & APIs
·6 min read

Backend Architecture Explained: REST APIs, Microservices and Scalable Databases

How to design REST APIs developers enjoy using, when microservices are worth their cost, how to model a database that holds up under load, and the patterns that keep backend systems fast and reliable.

View details
Web Development
·6 min read

Modern Web Development with Next.js, React and TypeScript: Best Practices

Server Components, rendering strategies, type-safe data and Core Web Vitals. A practical guide to building fast, maintainable web applications with the stack most teams choose today.

View details
AI & Machine Learning
·7 min read

AI and Machine Learning in Practice: Where They Deliver Real Business Value

Cut through the hype. Learn how machine learning and generative AI actually work, where they create measurable value today, and how to run a first AI project that ships instead of stalling as a demo.

View details