---
title: "Hexagonal architecture for real, not in theory"
description: "The starting problem, in a typical Adonis/Lucid backend, always looks the same: a service calls Skin.query().where(...) directly, it works, it's quick to write, and…"
date: "2026-06-24T09:00:00.000Z"
updated: "2026-08-24T15:05:22.368Z"
locale: "en"
canonical: "https://ninhache.fr/en/blog/hexagonal-en-pratique"
author: "Néo Almeida"
tags: "architecture, hexagonal"
categories: "dev"
---

# Hexagonal architecture for real, not in theory

The starting problem, in a typical Adonis/Lucid backend, always looks the same: a service calls `Skin.query().where(...)` directly, it works, it's quick to write, and six months later you can't test a single business rule without spinning up a Postgres database. The ORM model and the domain logic are welded together. Changing a storage detail means touching the service. Testing a permission rule means seeding tables.

On the Wakfuli backend, most business modules (items, folders, actions, auth, havenbag, builds, skinator, sublimations) avoid this trap with the same recurring split: `domain/`, `application/` (the ports), `adapters/`, `services/`, `controllers/`, `presenters/`, `validators/`. This isn't ceremony bolted on after the fact, it's the default folder structure of a module. The `spells` module, on the other hand, has neither `application/` nor `adapters/`: it exposes read-only reference data, with no business rule to isolate, and nobody forced the mold onto it. That detail matters: the discipline here isn't dogmatic, it applies where there's a boundary worth defending.

## A port and its adapter, not an abstract promise

Take the skinator module, which handles character skin creation. `skinator/application/ports.ts` defines the interface the domain expects from a skin repository, with no knowledge of Lucid or Postgres:

```ts title="skinator/application/ports.ts"
  findByUuidAndUser(uuid: string, userId: number): Promise<Skin | null>;
  findByUuid(uuid: string): Promise<Skin | null>;
  create(data: CreateSkinData): Promise<Skin>;
  mergeAndSave(skin: Skin, data: UpdateSkinData): Promise<Skin>;
  delete(skin: Skin): Promise<void>;
  getUserSkins(userId: number, page: number, limit: number, filters?: SkinFilters): Promise<ModelPaginatorContract<Skin>>;
  getPublicSkins(page: number, limit: number, filters?: SkinFilters): Promise<ModelPaginatorContract<Skin>>;
}
```

`skinator/adapters/skin_repository_adonis.ts` implements this interface with Lucid, SQL queries included:

```ts title="skinator/adapters/skin_repository_adonis.ts"
  async findByUuid(uuid: string): Promise<Skin | null> {
    return Skin.query().where('uuid', uuid).first();
  }

  async create(data: CreateSkinData): Promise<Skin> {
    return Skin.create({
      userId: data.userId ?? null,
      name: data.name.trim(),
      breedId: data.breedId,
      // ...
    });
  }
  // mergeAndSave, delete, getUserSkins, getPublicSkins follow the same pattern
}
```

**Controller**

The `skins_controller.ts` controller imports neither Lucid nor the `Skin` model directly.

**Service**

It goes through `SkinsService`, which exposes a `SkinPresenter` on output.

**Port**

The controller -> service -> port chain is respected end to end, not just on paper.

## Where it really costs you, and where it cheats a little

This is where honesty starts. The domain isn't as pure as the folder structure suggests. `skinator/domain/skin.ts`, supposedly the business entity, is actually a Lucid model: it extends `BaseModel`, carries `@column` decorators, knows its table name. It's not a business object decoupled from persistence in the strict DDD sense, it's an Active Record. What the port isolates isn't the shape of the entity, it's how you access it: the queries, the filters, the pagination. The hexagonal boundary here protects data access, not the entity itself. It's a deliberate compromise, not textbook hexagonal, and that's better than a hollow port pretending otherwise.

A second, subtler nuance: `SkinsService` declares its constructor with `private readonly repo: AdonisSkinRepository`, the concrete class, not `SkinRepository`, the interface. The reason is pragmatic: AdonisJS's IoC container automatically resolves concrete classes marked `@inject()`, with no explicit binding to register for an abstract type. The port exists, the class honors it, the shared types (`SkinFilters`, `CreateSkinData`) do come from `ports.ts`.

**Pitfall**

But nothing mechanically prevents calling a method that isn't in the interface. Swapping the adapter for a test means either registering a binding in the container, or providing a fake object with the same shape. It's a real discipline cost, not a mechanical one: nothing stops you if you cut the corner.

## What it buys you anyway

What this split makes possible, concretely:

**Testability**

Test the logic in `SkinsService` (folder resolution, token generation for an anonymous skin, `breedId` computation) by passing it a fake repository that matches the shape of `SkinRepository`, with no database, no migration, no SQL fixture to maintain.

**Readability**

Opening `services/skins_service.ts` gives you the business logic with no SQL noise, and opening `adapters/skin_repository_adonis.ts` gives you the SQL with no business noise. Each file answers a single question.

The cost, though, is real and shouldn't be glossed over: nine modules with this split means dozens of small files for operations that, in a more direct style, would fit in a single controller class. On this backend, the application code as a whole runs past 14,000 lines spread across these layers, for a medium-sized product. Part of that volume is structural: an interface that repeats the signatures of its implementation, a presenter that reformats what the service just computed. It's the kind of detail that makes some people say hexagonal, below a certain team or domain size, looks like folder theater: form without function, layers you pass through without them protecting anything, because nobody enforces them over time.

Here, what avoids that trap isn't the presence of the `application/` and `adapters/` folders, it's that they're actually traversed the right way: no controller touches Lucid, no service imports a model outside its own module without going through a repository, and the module that doesn't need the split (spells) simply wasn't given it. The discipline is verified by grepping, not by reading folder names.

**Lesson**

The takeaway isn't "always do hexagonal" nor the opposite. It's that this pattern pays back its cost when there's a real boundary to protect (storage that might change, a business rule you want to test without an external dependency, a growing team that needs responsibilities to be locatable), and it becomes dead weight the moment you apply it by reflex to a module with neither business complexity nor a need for substitution. The compromise on domain purity, here, isn't a failure of the pattern: it's the sign that it was applied with a precise goal (isolating data access) rather than as a checklist to tick off.
