Clean Architecture at Joko, or How to Make a Codebase Someone Else Can Change

Every codebase starts clean. The tangle arrives quietly, one reasonable shortcut at a time: a database call slipped into a pricing function, an environment variable read three layers deep, a test that needs a running container because there’s no other way to reach the code.

At Joko we’re building Juno, an AI shopping assistant, on a platform that ingests millions of product offers a day and keeps evolving under full production load. Our monorepo holds dozens of services, and the person maintaining a piece of code today is rarely the person who wrote it. So we optimize for one thing above almost everything else: can the next person change this safely?

This post walks through the four practices that get us there: our GOAL (readable code), the SHAPE it takes (clean interfaces), the MECHANISM it obeys (ports, adapters, dependency injection) and the LAYOUT it follows (concentric layers).

A small note: None of this is invented here. It’s our own selection from hexagonal architecture, clean architecture, and DDD. We kept what earns its keep and dropped what felt cumbersome. We tried to make it as simple and as teachable as possible: this is genuinely what we hand newcomers today. We apply it in an opinionated way, but it makes our lives easier, not harder: it doesn’t feel like a set of painful rules slowing everything down. This post is a pedagogical walkthrough of how we apply it in practice; a full bibliography is at the end for anyone who wants to go deeper.


1. ๐Ÿ’  We write code others can read and modify

We write code that others can read, understand, and modify with confidence, including future us.

Most of the time, the person changing your code will not be you. And even when it is, you won’t remember what you were thinking when you first wrote it.

Readable code makes teams faster. It lowers cognitive load, reduces mistakes, and makes reviews and refactors easier. It allows engineers to safely improve systems they didn’t originally build. This means favoring clarity over cleverness, explicit over implicit, simple structures over smart tricks. If code requires mental gymnastics to understand, it’s a liability.


2. ๐Ÿค We separate concerns by enforcing clean interfaces

Poorly structured code (often called spaghetti code) has familiar symptoms:

  • everything depends on everything else
  • small changes break unrelated behavior
  • bugs are hard to trace
  • tests are brittle or impossible

This happens when business logic absorbs infrastructure details. Dependencies leak in all directions, boundaries disappear, and the system becomes fragile.

Spaghetti code vs. clean interfaces

At Joko we build systems that enforce clear boundaries so complexity stays local:

  • We expose clear, intentional interfaces between concerns.
  • We keep business logic independent from infrastructure. Business rules describe what the system does and why; infrastructure handles how it is executed: databases, APIs, queues, frameworks, external services.
Clean interfaces make the code easy to modify

This makes systems fast to test and able to evolve without massive rewrites. But “expose a clean interface” is easy to nod along to and hard to act on, so here’s the mechanism.

Clean interfaces make the code easy to test

3. ๐Ÿ”Œ We use ports, adapters, and dependency injection

The goal is simple: write business logic without hard-coding infrastructure. Business logic describes what happens; infrastructure describes how. Mixed together, code becomes tightly coupled, hard to test, and fragile.

โŒ What we avoid: logic entangled with infrastructure

async function redeemReward(userEmail: string, rewardId: string) {
  const dynamoDbQueryResult = await dynamoClient
    .get({ TableName: process.env.REWARDS_TABLE, Key: { rewardId } })
    .promise();

  if (!dynamoDbQueryResult.Item) throw new Error("Reward not found");

  const reward = dynamoDbQueryResult.Item;
  if (!reward.available) throw new Error("Reward unavailable");

  await fetch(
    `https://api.ifeelgoods.com/v1/users/${userEmail}/rewards/${reward.internalRewardId}`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.REDEMPTION_API_TOKEN}`,
        "Content-Type": "application/json",
      },
    },
  );
}

Business rules depend directly on DynamoDB and the iFeelGoods API. Testing requires mocking AWS and network calls. Changing storage or API breaks the workflow. There is no boundary: everything is entangled.

๐Ÿ”Œ Ports

A port is an interface defined by the inner logic that expresses a need: “I need to fetch a reward”, “I need to trigger a redemption”, “I need to persist something”. Ports are written in the language of the business, free of SDKs, HTTP, databases, or frameworks.

Ports diagram
interface RewardRepository {
  getById(id: string): Promise<Reward | null>;
}

interface RedemptionService {
  redeem(userEmail: string, reward: Reward): Promise<void>;
}

At this point nothing knows about what infrastructure we use. The logic only states what it needs.

๐Ÿงฉ Adapters

An adapter is a concrete implementation of a port. Adapters live at the edges and deal with real-world details: databases, HTTP calls, SDKs, authentication, retries, timeouts, serialization.

Ports define what is needed. Adapters define how it’s done. One port can have several adapters.

Adapters diagram Adapters diagram (continued)

Production adapter

const dynamoDbRewardRepositoryCreator = ({
  dynamoClient,
  tableName,
}: {
  dynamoClient: DynamoDB.DocumentClient;
  tableName: string;
}): RewardRepository => ({
  async getById(id: string): Promise<Reward | null> {
    const result = await dynamoClient
      .get({ TableName: tableName, Key: { rewardId: id } })
      .promise();

    return result.Item ?? null;
  },
});

In-memory adapter (tests, local development)

๐Ÿ“Œ An in-memory adapter implements a port using memory instead of external systems. It gives you fast tests (no network, no setup), local development without AWS or Docker, and predictable state you control exactly.

const inMemoryRewardRepositoryCreator = (
  rewards: Map<string, Reward>,
): RewardRepository => ({
  async getById(id: string): Promise<Reward | null> {
    return rewards.get(id) ?? null;
  },
});

โš ๏ธ Ports without dependency injection

Refactoring the logic to use ports looks better, but if the use case builds its own adapters:

const rewardRepository = dynamoDbRewardRepositoryCreator({
  dynamoClient,
  tableName: process.env.REWARDS_TABLE!,
});

โ€ฆinfrastructure choices are still hard-coded and testing still needs real dependencies or heavy mocking. Ports exist, but the boundary is fake, because the logic still creates its own adapters.

โœ… Dependency injection: making ports real

One principle: do not create dependencies where you use them, receive them from the outside. The logic declares what it needs, not how it’s built.

interface Dependencies {
  rewardRepository: RewardRepository;
  redemptionService: RedemptionService;
}

function createRedeemRewardUseCase({
  rewardRepository,
  redemptionService,
}: Dependencies) {
  return async function redeemReward(userEmail: string, rewardId: string) {
    const reward = await rewardRepository.getById(rewardId);

    if (!reward) throw new Error("Reward not found");
    if (!reward.available) throw new Error("Reward unavailable");

    await redemptionService.redeem(userEmail, reward);
  };
}

Now the logic has no idea which infrastructure is used, dependencies are explicit, the function is trivial to test, and infrastructure can change independently.

๐Ÿ”ฉ Wiring: the composition root

One place (and only one) chooses adapters.

// tests, local dev
export const redeemReward = createRedeemRewardUseCase({
  rewardRepository: inMemoryRewardRepositoryCreator(
    new Map([
      /* ... */
    ]),
  ),
  redemptionService: inMemoryRedemptionServiceCreator(),
});

// production
export const redeemReward = createRedeemRewardUseCase({
  rewardRepository: dynamoDbRewardRepositoryCreator({
    dynamoClient,
    tableName: process.env.REWARDS_TABLE!,
  }),
  redemptionService: iFeelGoodsRedemptionServiceCreator({
    apiToken: process.env.REDEMPTION_API_TOKEN!,
  }),
});

The use case code never changes.


4. โญ• We use concentric layers

๐ŸŽฏ From decoupling to structure

Ports, adapters, and dependency injection give us decoupled pieces. They don’t say where those pieces live. And a codebase where everything is technically decoupled but nothing has an obvious home is only marginally better than a tangle.

Take a simple workflow: placing an order. It applies ๐Ÿงฎ pricing rules, loads ๐Ÿ‘ค a customer from the database, charges ๐Ÿ’ณ a payment method via a gateway, and persists ๐Ÿ“ฆ an order via an external service. Implementing it gives us five kinds of building block: business rules, ports, a use case that orchestrates them, adapters, and wiring. All the pieces exist but how should they be positioned relative to each other?
Placing an order building blocks

We follow one idea: keep business logic stable at the core, and push implementation details to the edges. That leads to concentric layers with a strict dependency direction: the center makes decisions, the outside performs effects.

Concentric layers

๐ŸŸ  Domain: business rules

The domain holds the business logic itself. The term comes from Domain-Driven Design: the domain models the problem space we’re solving.

It contains types that model the problem space, pure business rules, and ports. It contains no databases, HTTP calls, SDKs, or serialization. The domain is pure: given inputs, it returns outputs, with no side effects.

// domain/pricing.ts
export function priceOrder(input: PricingInput): PricingBreakdown {
  const subtotal = input.items.reduce(
    (sum, item) => sum + item.unitPriceCents * item.quantity,
    0,
  );

  const tierDiscount =
    input.customer.tier === "premium" ? Math.floor(subtotal * 0.05) : 0;

  const couponDiscount =
    input.coupon?.kind === "percent"
      ? Math.floor(subtotal * (input.coupon.percentOff / 100))
      : (input.coupon?.amountOffCents ?? 0);

  const discount = Math.min(subtotal, tierDiscount + couponDiscount);

  return {
    subtotalCents: subtotal,
    discountCents: discount,
    totalCents: subtotal - discount,
  };
}

The domain is also where ports live. It’s the needs, with no word about how they’ll be met:

// domain/ports.ts
export interface CustomerRepository {
  // ๐Ÿ‘ค load a customer
  getById(customerId: string): Promise<Customer>;
}

export interface PaymentGateway {
  // ๐Ÿ’ณ charge a payment
  charge(params: {
    customerId: string;
    amountCents: MoneyCents;
  }): Promise<{ paymentId: string }>;
}

export interface OrderService {
  // ๐Ÿ“ฆ persist an order
  create(params: {
    customerId: string;
    items: LineItem[];
    pricing: PricingBreakdown;
    paymentId: string;
  }): Promise<{ orderId: string }>;
}

๐Ÿ”ต Application: use cases

The application layer answers “what does the user want to accomplish?” It holds use cases: high-level scenarios describing what the system can do.

Use cases are orchestrators: they fetch the facts they need via ports, apply domain rules, coordinate the workflow, and trigger effects via ports. They contain no business rules themselves. The what and why live in the domain; the when and in what order live here. Thanks to dependency injection, this layer knows the domain but not infrastructure.

// application/placeOrder.useCase.ts
export const placeOrderUseCaseCreator = ({
  customerRepository,
  paymentGateway,
  orderService,
}: Dependencies) => {
  return async ({ customerId, items, coupon }: PlaceOrderRequest) => {
    const customer = await customerRepository.getById(customerId); // ๐Ÿ‘ค
    const pricing = priceOrder({ items, customer, coupon }); // ๐Ÿงฎ
    const payment = await paymentGateway.charge({
      // ๐Ÿ’ณ
      customerId,
      amountCents: pricing.totalCents,
    });
    const order = await orderService.create({
      // ๐Ÿ“ฆ
      customerId,
      items,
      pricing,
      paymentId: payment.paymentId,
    });

    return { orderId: order.orderId, pricing };
  };
};

๐ŸŸข Infrastructure: effects, integration, and wiring

The infrastructure layer is everything touching the outside world: databases (DynamoDB, Snowflake), HTTP clients, queues, handlers (Lambdas, APIs), logging, metrics, tracing.

It holds the adapters implementing the domain’s ports, and the composition root where everything is wired together. Infrastructure can be complex and messy: that’s its job. The complexity just has to stay outside the core.

// infrastructure/placeOrder.ts
const customerRepository = dynamoDbCustomerRepositoryCreator({
  dynamoClient: new DynamoDB.DocumentClient(),
  tableName: process.env.CUSTOMERS_TABLE!,
});

const paymentGateway = stripePaymentGatewayCreator({
  stripeClient: new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: "2023-10-16",
  }),
});

const orderService = httpOrderServiceCreator({
  baseUrl: process.env.ORDER_SERVICE_URL!,
  apiKey: process.env.ORDER_SERVICE_API_KEY!,
});

export const placeOrder = placeOrderUseCaseCreator({
  customerRepository,
  paymentGateway,
  orderService,
});

โžก๏ธ Dependency direction

Layers are only real if the arrows go one way:

  • Infrastructure can import Application and Domain
  • Application can import Domain
  • Domain must import nothing

Inner layers define the rules; outer layers adapt them to the real world. If the domain imports infrastructure, the core is no longer stable.

Dependency direction between layers

๐Ÿ˜๏ธ Bounded contexts

As systems grow, one set of concentric layers isn’t enough: different parts of the system have genuinely different concerns that don’t fit a single unified model. A large system is better understood as multiple bounded contexts, each with its own layers.

A bounded context (also from DDD) is a boundary within which one domain model applies consistently. Different contexts may model the same concept differently: an e-commerce system might have shopping, catalog, payment, order management, fulfillment, customer, and analytics contexts, and “Product” means something slightly different in each.

Bounded contexts

Inside each context, the same three layers apply. Between contexts, communication happens at the infrastructure layer: HTTP APIs, message queues, or direct imports through clean interfaces/SDKs when contexts share a runtime. Contexts then evolve independently, with boundaries and dependencies still explicit.


What this buys us

Four practices, one thread: decide in the center, act at the edges, and never let the edges leak inward. What is presented above is just the main pieces from a larger toolkit. We also rely heavily on test pyramids, error monads rather than thrown exceptions, and so on. Consider this the foundation on which the rest is built.

In practice, here’s what this actually buys us:

  • Tests run in milliseconds with no infrastructure. Every dependency is a port, so the default way to test a use case is to inject in-memory adapters: no Docker, no LocalStack, no credentials. That one property changes how often people test, which changes how often people refactor.
  • Infrastructure decisions become reversible. Swapping a storage engine or a payment provider is a new adapter plus one line at the composition root. The use case doesn’t move.
  • New engineers know where to look for any given rule. “Where does this live?” has the same answer no matter who’s asking. Onboarding stops being an oral tradition passed down person to person.
  • Complexity stays local. A flaky third-party API or a weird serialization format has an obvious place to be contained, outside the business logic.

The honest costs:

  • More files, more indirection. That cost is worth paying for anything with real logic or a long lifespan, but for a one-off script or a thin CRUD passthrough, it isn’t, so we skip the pattern there.
  • Ports can be over-abstracted. A port with exactly one adapter that will only ever have one adapter is sometimes just a longer way to call a function. We draw boundaries where they’re real, not on principle.
  • The discipline needs enforcing. One import from domain into infrastructure quietly deletes the benefit, so layer boundaries are checked by tooling and in review, not remembered.

The test we actually use isn’t “does this look hexagonal?” It’s the one we started with: how long would it take someone who didn’t write this to change it, and would they be confident shipping it? These four practices are the most reliable answer we’ve found.


We’re hiring

Evolving a platform while it serves millions of users (and keeping it legible enough that a small team keeps moving fast) is the everyday reality of building Juno. If solving hard problems with a high standard of craftsmanship sounds like your kind of work, we’re actively recruiting in France and Spain โžก๏ธ https://www.welcometothejungle.com/en/companies/joko/jobs ๐Ÿ‘‹

Joko hiring banner

๐Ÿ“š Further reading

The foundations

  • Alistair Cockburn, Hexagonal Architecture: the original 2005 article, still the clearest statement of the idea.
  • Robert C. Martin, The Clean Architecture: the concentric circles and the dependency rule; expanded in Clean Architecture (2017).
  • Eric Evans, Domain-Driven Design (2003): “domain”, “bounded context”, “ubiquitous language”. Dense; the free DDD Reference is a good shortcut. Vaughn Vernon’s Implementing DDD (2013) is the code-heavy companion.

Dependency injection and testing

  • Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern: why DI is a pattern first and a framework second (we use no framework).
  • Mark Seemann & Steven van Deursen, Dependency Injection Principles, Practices, and Patterns (2019): especially the composition-root chapters.
  • Gary Bernhardt, Boundaries: “functional core, imperative shell”, and the best 30 minutes on why pure cores are easy to test.
  • Martin Fowler, Mocks Aren’t Stubs: background for preferring real in-memory adapters over mocking libraries.

Readability

  • Dave Thomas & Andy Hunt, The Pragmatic Programmer (20th Anniversary Edition, 2019).
  • Kent Beck, Tidy First? (2023): small, safe structural improvements and when to make them.

From our own blog

  • From Fragile Pipelines to Durable Workflows: ports and adapters applied to workflow orchestration: the same engine against real AWS in production and in-memory adapters in tests. Read the post.