Hexagonal Architecture and beyond

IMT Atlantique - 22 Septembre 2025

Hey 👋, I'm Anaël Chardan

Graduated in 2018

Core developer at Akeneo

Senior Engineer at Spendesk

Founding Engineer at Hyperline

Agenda

1. S.O.L.I.D

2. Hexagonal architecture theory and live coding

 

As software engineers what is our goal ?

Delivering business value while building a software

What makes a good software (as engineer)?

- Easy to test

- Easy to evolve / maintain

- Easy to understand

- Easy to release

- Secured
 

We want to avoid

  • Toxic code

  • Uncontrollable code

  • Uncontrolled technical debt

S.O.L.I.D

Single Responsability Principle (SRP)

Definition

The Single Responsibility Principle states that a class or a function should have only one job or responsibility. In other words, it should only do one thing and do it well.

Definition

Le principe de responsabilité unique (SRP) stipule qu'une classe ou un module ne doit avoir qu'une seule raison de changer, c'est-à-dire être responsable d'une seule fonctionnalité ou tâche.

interface BoardgameService {
  playMove(row: number, column: number): void;
  renderBoard(): void;
  logMove(row: number, column: number): void;
}
interface BoardRenderer {
  render(board: Board): void
}

interface PlayLogger {
  log(play: Play): void
}

interface Play {
  move(row: number, column: number): void
}

Examples

Violating principle

Not Violating principle

Pros

  • Easier to read
  • Easier to understand
  • Easier to test
  • Easier to maintain

Open/Close principle

Definition

Software components (like classes, modules, or functions) should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.

Definition

Une classe doit être ouverte à l'extension, mais fermée à la modification.

Examples

class MoveHandler {
    public handleMove(move: any): void {
        if (move.type === 'placePiece') {
            console.log(`Placing piece at (${move.x}, ${move.y})`);
        } else if (move.type === 'drawCard') {
            console.log(`Drawing a card from the ${move.deck} deck`);
        } else if (move.type === 'rollDice') {
            console.log(`Rolling ${move.dice} dice`);
        } else {
            throw new Error('Move type not supported');
        }
    }
}

Violating principle

interface Move {
    execute(): void;
}

class PlacePieceMove implements Move {
    execute(): void { /** ... */ }
}

class DrawCardMove implements Move {
    execute(): void { /** ... */ }
}

class MoveHandler {
    public handleMove(move: Move): void {
        move.execute();
    }
}

Highlighting principle

Pros

  • Helps prevent breaking existing features when new features are added.
  • Makes your code more flexible and easier to expand over time.
  • Supports adding new behavior with minimal changes

Liskov Substitution Principle

Definition

The Liskov Substitution Principle (LSP) states that objects of a subclass should be able to replace objects of a superclass without altering the correctness of the program. In simpler terms, a subclass should behave like its parent class, so you can use it wherever the parent class is expected without causing errors or unexpected behavior.

Definition

Les objets d'une classe dérivée doivent pouvoir remplacer les objets de leur classe de base sans altérer le comportement du programme.

Example

Violating principle

Not violating principle

class BoardGame {
    protected players: string[] = [];

    public addPlayer(player: string): void {
        this.players.push(player);
        console.log(`Adding player: ${player}`);
    }
}
class ChessGame extends BoardGame {
    public addPlayer(player: string): void {
        super.addPlayer(player);  // Leverage the base class functionality
        if (this.players.length > 2) {
            console.log("Chess usually requires exactly 2 players, but the game can still be set up.");
        }
    }
}

Pros

  • Code Reusability: You can use subclasses anywhere the parent class is used.

  • Flexibility: Easily extend your code with new features without changing existing code.
  • Polymorphism: Helps you write more generic and scalable code

Interface segregation principle

Definition

Clients should not be forced to depend on interfaces they do not use

Definition

Une interface ne doit pas obliger une classe à implémenter des méthodes dont elle n'a pas besoin.

Example

Violating principle

Not violating principle

interface Game {
    startGame(): void;
    addPlayer(player: string): void;
}

interface CardGame extends Game {
    drawCard(deck: string): void;
}

//...
interface BoardGame {
    startGame(): void;
    addPlayer(player: string): void;
    drawCard(deck: string): void;   // Not all games use cards
    rollDice(numDice: number): void;  // Not all games use dice
}

class ChessGame implements BoardGame {
    //...

    public drawCard(deck: string): void {
        throw new Error("Chess game does not support drawing cards.");
    }
}

Pros

  • Simplicity: interfaces are small and focused

  • Maintenability

  • ... you get the thing 🙈

Dependency inversion principle

Definition

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Abstractions should not depend on details. Details should depend on abstractions.

Definition

Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau, mais tous deux doivent dépendre d'abstractions.

Example

Violating principle

Not violating principle

interface DiceRoller {
    rollDice(numDice: number): number[];
}

// Concrete implementation of the abstraction
class StandardDiceRoller implements DiceRoller {
    public rollDice(numDice: number): number[] {
        const rolls = [];
        for (let i = 0; i < numDice; i++) {
            rolls.push(Math.floor(Math.random() * 6) + 1);
        }
        return rolls;
    }
}

// High-level module depending on the abstraction
class GameManager {
    private diceRoller: DiceRoller;

    constructor(diceRoller: DiceRoller) {
        this.diceRoller = diceRoller;  // Dependency injection
    }

    public playGame(): void {
        const diceResults = this.diceRoller.rollDice(2);
        console.log(`Dice results: ${diceResults.join(', ')}`);
    }
}
class DiceRoller {
    public rollDice(numDice: number): number[] {
        const rolls = [];
        for (let i = 0; i < numDice; i++) {
            rolls.push(Math.floor(Math.random() * 6) + 1);
        }
        return rolls;
    }
}

// High-level module that depends directly on the low-level module
class GameManager {
    private diceRoller: DiceRoller;

    constructor() {
        this.diceRoller = new DiceRoller();  // Direct dependency
    }

    public playGame(): void {
        const diceResults = this.diceRoller.rollDice(2);
        console.log(`Dice results: ${diceResults.join(', ')}`);
    }
}

Pros

  • Flexibility: Easily extend your code with new features without changing existing code.

  • It helps the testing with easier injection

3 Tiers architecture

Fragile

Coupling = Fragile

Hexagonal Architecture

Low coupling / High Cohesion

Focus on the domain
And manage the risks of I/O

What is an I/O ?

Everything that you cannot control

  • Time
  • Network
  • Disk
  • Cache
  • Database
  • API
  • Randomness

Control the complexity

and accidental complexity (developer comprehension / framework / over-engineering / just in case)

Without decoupling

With decoupling

complexity = accidental * mandatory * essential

complexity = accidental + mandatory + essential

What's a port ?

Driver Ports offer the application functionality to drivers of the outside world. Thus, driver ports are said to be the use case boundary of the application. They are the API of the application.

A driven port is an interface for a functionality, needed by the application for implementing the business logic. Such functionality is provided by a driven actor. So driven ports are the SPI (Service Provider Interface) required by the application. A driven port would be like a Required Interface.

What's an adapter?

A driver adapter uses a driver port interface, converting a specific technology request into a technology agnostic request to a driver port.

A driven adapter implements a driven port interface, converting the technology agnostic methods of the port into specific technology methods.

Title Text

Subtitle

Let's code !

Our use case

I want to be able to save my boardgames plays with a specific number of players.

I want to validate the number of players thanks to BoardGameGeek API

Start with the domain !

switch to branch 1_functional

What do we want?

1. Our domain model: Boardgame + Play

 

2. a use case: be able to save a game with a correct number of players

Results

Let's interact with it!

Switch to branch 2_controller

What do we want?

1. An HTTP Framework (Fastify)

2. Interact with our use case

Results

Let's add the interaction with BGG

Switch to branch 3_functional

Results

As a result

We have an highly decoupled code
Easy to test
Focused on business logic

What's next ?

Other decoupling possibles:

      - Separate Command and Queries Concerns
      - Addition of service buses to communicate to use cases (e.g Missive.js)
      - Dependency injection frameworks (e.g Awilix)

Ressources

Links

Made with Slides.com