MODEL EXPRESSIONS

An Overlooked Aspect of Domain-Driven Design?

Julien Topçu

FOR YOUr EYES ONLY

◆ Tactical Variance Authority ◆

Problem statement

THE CRIME SCENE

Variants HUNT

sealed class Criteria { //ValueObject
    data class OneWay(
        val outbound: Journey,
        val directTrainOnly: Boolean,
    ) : Criteria()

    data class RoundTrip(
        val outbound: Journey,
        val inbound: Journey,
        val directTrainOnly: Boolean,
    ) : Criteria()
}
class Search( // Aggregate
    val id: UUID,
    val criteria: Criteria,
    val trains: List<Train>,
    val selection: Selection
)
data class Journey( //ValueObject
    val departureStationId: String,
    val arrivalStationId: String,
    val departureSchedule: OffsetDateTime
)

THE CODE

Sacred Model

THE line up

Variants HUNT

suspects

THE LIE DETECTOR

WEAK SPOT

Search

Aggregate

Criteria

Value Object

1

1

THE Inliner

suspect#1

CREATE TABLE search (
      id                  		   CHAR(36)     PRIMARY KEY,
      trip_type           		   VARCHAR(16)  NOT NULL,
      direct_train_only   		   BOOLEAN      NOT NULL,
      selection_id		  		   CHAR(36)	   	REFERENCES selection(id),
      -- ...
  );

Schema

idtrip_typedirect_train_onlyselection_id...
search#1ROUND_TRIPtrueselection#1

Search

Criteria

Table

Variant

Criteria is now an Entity with its own identity!

Schema

CREATE TABLE search (
      id			CHAR(36)     PRIMARY KEY,
      criteria_id	CHAR(36)	 NOT NULL UNIQUE REFERENCES criteria(id),
      selection_id	CHAR(36)	 REFERENCES selection(id),
      -- ...
  );

CREATE TABLE criteria (
    id          	   CHAR(36)     PRIMARY KEY,
    trip_type          VARCHAR(16)  NOT NULL,
    direct_train_only  BOOLEAN      NOT NULL,
    -- ...
);

THE FORGER

suspect#2

THE FORGER

suspect#2

idselection_idcriteria_id...
search#1selection#1criteria#1...

Search

Table

idtrip_typedirect_train_only...
criteria#1ROUND_TRIPtrue...

CRITERIA

Table

THE FORGER

suspect#2

Variant

idselection_idcriteria_id...
search#1selection#1criteria#1...

Search

Table

idtrip_typedirect_train_only...
criteria#1ROUND_TRIPtrue...
criteria#2ONE_WAYfalse...

CRITERIA

Table

Criteria is still an Entity!

The Search doesn't know it contains Criteria!

THE IMpersonator

suspect#3

Schema

CREATE TABLE search (
      id                  		   CHAR(36)     PRIMARY KEY,
      selection_id		  		   CHAR(36)	   	NULL,
      -- ...
  );

CREATE TABLE criteria (
    search_id          CHAR(36)     PRIMARY KEY REFERENCES search(id),
    trip_type          VARCHAR(16)  NOT NULL,
    direct_train_only  BOOLEAN      NOT NULL,
    CHECK (trip_type IN ('ONE_WAY', 'ROUND_TRIP'))
);

THE IMpersonator

suspect#3

idselection_id...
search#1selection#1...
search#2selection#2

Search

Table

idtrip_typedirect_train_only...
search#1ROUND_TRIPtrue...

CRITERIA

Table

Variant

What ties the number of journeys to the type of Criteria?

THE IMpersonator

suspect#3

CREATE TABLE criteria (
    search_id          CHAR(36)     PRIMARY KEY REFERENCES search(id),
    trip_type          VARCHAR(16)  NOT NULL,
    direct_train_only  BOOLEAN      NOT NULL,
    CHECK (trip_type IN ('ONE_WAY', 'ROUND_TRIP')) 
    -- ONE_WAY = 1 Journey, ROUND_TRIP = 2 Journeys?
);

CREATE TABLE journey (
    criteria_id          CHAR(36)    NOT NULL REFERENCES criteria(search_id),
    bound                VARCHAR(8)  NOT NULL,
    departure_station_id CHAR(36)    NOT NULL,
    departure_schedule   CHAR(25)    NOT NULL,
    arrival_station_id   CHAR(36)    NOT NULL,
    PRIMARY KEY (criteria_id, bound),
    CHECK (bound IN ('OUTBOUND', 'INBOUND'))
);

SCHEMA

Plain SQL cannot enforce this. CHECK is row-local.

THE IMpersonator

suspect#3

ALTER TABLE criteria
ADD CONSTRAINT journey_count_matches_trip_type 
CHECK (
    (trip_type = 'ONE_WAY'
        AND (SELECT COUNT(*) FROM journey WHERE criteria_id = search_id) = 1)
 OR (trip_type = 'ROUND_TRIP'
        AND (SELECT COUNT(*) FROM journey WHERE criteria_id = search_id) = 2)
);

SCHEMA

Illicit
in SQL

OneWay has one journey. RoundTrip has two.

Invariant

THE IMpersonator

suspect#3

idbounddeparture_station_idarrival_station_id...

Journey

Table

idtrip_typedirect_train_only...

Criteria

Table

a ROUND_TRIP with one journey? accepted!

THE IMpersonator

suspect#3

idbounddeparture_station_idarrival_station_id...
search#1OUTBOUNDPARLON...

Journey

Table

idtrip_typedirect_train_only...
search#1ROUND_TRIPtrue...

Criteria

Table

a ROUND_TRIP with one journey? accepted!

THE IMpersonator

suspect#3

a ONE_WAY with an INBOUND? accepted!

idbounddeparture_station_idarrival_station_id...
search#1OUTBOUNDPARLON...
search#2INBOUNDBRUAMS

Journey

Table

idtrip_typedirect_train_only...
search#1ROUND_TRIPtrue...
search#2ONE_WAYtrue

Criteria

Table

THE IMpersonator

suspect#3

Variant

SQL is broken!
Tribunal order: Prune all of it!

verdict

Code : Round-trip has exactly 2 journeys

SACRED MODEL

SQL : Criteria has journeys, never how many

VARIANCE

THE LIE DETECTOR

WEAK SPOT

Search
  change(criteria): Search
  select(...): SearchWithIncompleteSelection

SearchWithIncompleteSelection
  // no change(criteria), frozen after the first select
  select(...):   SearchWithCompleteSelection
  unselect(...): Search

SearchWithCompleteSelection
  // no change(criteria)
  unselect(...): SearchWithIncompleteSelection
  book(): Booking

SACRED MODEL

The rule never reaches the wire! 

Variant

THE CRUD-Slinger

suspect#4

EVIDENCE #1: no selection yet

PATCH /searches/abc/criteria HTTP/1.1
Content-Type: application/json

{ "directTrainOnly": true }
HTTP/1.1 200 OK

Request

response

PATCH /searches/abc/criteria HTTP/1.1
Content-Type: application/json

{ "directTrainOnly": false }

EVIDENCE #2: SELECTED OUTBOUND

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{ "error": "Criteria can no longer be changed" }

Request

response

◆  TVA Tribunal · Pruning Orders  ◆

each describe a different Search within the same bounded-context

Every medium has its own grammar

Verdict

EXHIBIT A

CODE

BUILT FOR

BUSINESS RULES

EXHIBIT B

SQL

BUILT FOR

STORAGE

TO PRUNE

EXHIBIT C

REST

BUILT FOR

EXPOSURE

TO PRUNE

What is a model expression?

Ancillary
Ship

Select
Ancillary

Ancillary
Selected

Search
Bound

Fare
Selected On Bound

Check
Selection
Completion

Check
Selection
Completion

Selection
COmpleted

Selection

Selection Found Incomplete

Ancillaries

Retrieval

List
Ancillaries

Ship

Fare

Ancillaries
Listed

Ancillaries

Select
a Fare

Incomplete
Selection

complete
Selection

Price
Selection

Total Price Computation

Selection
Priced

Priced
Selection

Selection

Talking is modelling

We express the model in each layer of the system, each expression addressing a specific concern

 

 

That is a Model Expression

THE MODEL IS INTANGIBLE

statement

An expression preserves the dimensions that match its grammar's concern, and loses the rest

01

Each expression is

GRAMMAR-BOUNDED

02

Each grammar is

concern-focused

MODEL EXPRESSION

Constraints

EXHIBIT B

SQL

LOSES

BEHAVIOR

preserves

STRUCTURE

LOSES

WORKFLOW

preserves

RESOURCE STATE

EXHIBIT C

REST

EXHIBIT D

USER STORY

LOSES

STRUCTURE

preserves

INTENT

  • Business logic  Code

  • Persistence  SQL, MongoDB...

  • System Affordance  REST, GraphQL, Messages...

  • Presentation  UI, mobile...

  • Observability   Dashboards, bug tracker, metrics...

  • Structure / Modularity  Architecture

technical

one model, many Dimensions

  • SPECIFICATION  user stories, scenarios...

  • documentation

  • workflow  business processes

  • shared understanding  EventStorming

  • communication   talking is modelling

social

one model, many Dimensions

THE ORGANIZATION IS A MODEL EXPRESSION

The organization is a Solution to the problem, on the human dimension

User

Shopping

Payment

Billing

Social dimension

We only deal with expressions of an intangible model

through different dimensions of the socio-technical system

BUSINESS

Model Expression

org

Model Expression

Model Expression

architecture

process

Model Expression

The relationship between model expressions and a bounded-context

What is the link between a model expression and a bounded-context?

exploring The solution space

Speak a ubiquitous language within an explicit bounded-context.

— Eric Evans

What makes a bounded-context?

01

BOUNDED-CONTEXT

01

ubiquitous language

01

model

database

Model Expression

Model Expression

user Story

Domain code

Model Expression

Model Expression

UI

Bounded-context

Intangible Model

Before

As a traveller,
I want to search for a one-way or a round-trip,
so that I can find trains for my trip.

  • The traveller specifies a journey.
  • For a round trip, the traveller also specifies a return journey.

the User Story

Model Expressions relationship

After

As a traveller,
I want to search for a multi-destination trip,
so that I can plan it in one search.

  • The traveller specifies a first journey.
  • The traveller adds further journeys, each continuing from the previous arrival, after it.
Before

Before

THE User interface

Model Expressions relationship

After

THE User interface

Model Expressions relationship

After

THE CODE

Model Expressions relationship

After

sealed class Criteria {
    data class OneWay(val outbound: Journey) : Criteria()
    data class RoundTrip(val outbound: Journey, val inbound: Journey) : Criteria()
    data class MultiDestination(val journeys: List<Journey>) : Criteria()
}

Before

sealed class Criteria {
    data class OneWay(val outbound: Journey) : Criteria()
    data class RoundTrip(val outbound: Journey, val inbound: Journey) : Criteria()
}

Before

CREATE TABLE journey (
    ...
    bound  VARCHAR(8)  NOT NULL,
    PRIMARY KEY (criteria_id, bound),
    CHECK (bound IN ('OUTBOUND', 'INBOUND'))
);

THE database

Model Expressions relationship

After

CREATE TABLE journey (
    ...
    position  INT  NOT NULL,
    PRIMARY KEY (criteria_id, position)
);

Changing a business structuring constraint impact model expressions the same manner

Domain code

Model Expression

Outbound / Inbound

List<Journey>

database

Model Expression

Keyed by bound

Keyed by position

Model Expression

UI

2 fixed fields

Add-a-destination control

Model Expression

2 fixed journeys

List of journeys

user Story

Result

Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations

THE conway's Law

HOMOMORPHISM

— Melvin Conway

User

Billing

Payment

Shopping

User

Billing

Shopping

Payment

Each model expression will preserve the structure of the constraints and the relationships between the domain concepts to remain valid

THE HOMOMORPHISM

Model Expressions relationship

database

Model Expression

Model Expression

user Story

Domain code

Model Expression

Model Expression

UI

Bounded-context

Intangible Model

The validity of this system of model expressions depends on their extrinsic cohesion

Do these journeys need to link up?

database

Model Expression

Domain code

Model Expression

Model Expression

UI

Round Trip

has 2 journeys ?

Round Trip

has 2 adjacent journeys!

BOUNDED-CONTEXT

The system of cohesive model expressions is a bounded-context

Domain code

Model Expression

Fare Computation:
Map Reduce

Algorithm

database

Model Expression

DELEgation

Model Expression

Domain code

Model Expression

Fare Computation:
Map Reduce

Algorithm

database

Model Expression

DELEgation

Model Expression

REPLICATION

Model Expression

OFFLINE PWA

Model Expression

Business
Logic

Backend

Model Expression

Business
Logic

OFFLINE PWA

Model Expression

Business
Logic

Backend

Model Expression

Business
Logic

REPLICATION

Model Expression

Model Expressions challenges

Controller

Service

Persistence

class Search(
    val id: UUID,
    val criteria: Criteria,

    // selection, trains, ...
)
data class Criteria(
    val tripType: TripType,
    val directTrainOnly: Boolean,
    // journeys...
)

Service

Persistence

Controller

Persistence

CREATE TABLE search (
      id			CHAR(36)     PRIMARY KEY,
      criteria_id	CHAR(36)	 NOT NULL UNIQUE REFERENCES criteria(id),
      selection_id	CHAR(36)	 REFERENCES selection(id),
      -- ...
  );

CREATE TABLE criteria (
    id          	   CHAR(36)     PRIMARY KEY,
    trip_type          VARCHAR(16)  NOT NULL,
    direct_train_only  BOOLEAN      NOT NULL,
    CHECK (trip_type IN ('ONE_WAY', 'ROUND_TRIP'))
);

Surrogate Key Forger

class Search(
    val id: UUID,
    val criteria: Criteria,
)
data class Criteria(
    val tripType: TripType,
    val directTrainOnly: Boolean,
)

Service

Persistence

Tables

ORM

@Entity

@Entity

@OneToOne

@JoinColumn

@Table

@Table

@Id

@Id

ORM

@Table(name = "criteria")
data class Criteria(
    val tripType: TripType,
    val directTrainOnly: Boolean,
)

Persistence

Controller

Tables

ORM

@Entity
@Table(name = "search")
data class Search(
    @Id
    val id: UUID,

    @OneToOne
    @JoinColumn(name = "criteria_id")
    val criteria: Criteria,
)

@Id

@OneToOne

@JoinColumn

@Table

@Entity

@Table

@Id

@Entity

Service

+ ORM

@Entity
@Table(name = "search")
data class Search(
    @Id
    val id: UUID,

    @OneToOne
    @JoinColumn(name = "criteria_id")
    val criteria: Criteria,
)
@Entity
@Table(name = "criteria")
data class Criteria(
	@Id
    val id: UUID,
   
    val tripType: TripType,
    val directTrainOnly: Boolean,
)

Persistence

Controller

Tables

Service

+ ORM

@Entity

@Id

@OneToOne

@JoinColumn

@Table

@Entity

@Table

@Id

ORM

Variant

@Entity
@Table(name = "search")
data class Search(
    @Id
    val id: UUID,

    @OneToOne
    @JoinColumn(name = "criteria_id")
    val criteria: Criteria,
)
@Entity
@Table(name = "criteria")
data class Criteria(
	@Id
    val id: UUID,
   
    val tripType: TripType,
    val directTrainOnly: Boolean,
)

Controller

Service

+ ORM

Serializer

@JsonProperty

@JsonProperty

@JsonProperty

@JsonProperty

Serializer

Variant

GET /searches/{id}/criteria/{criteriaId}
@Entity
@Table(name = "criteria")
data class Criteria(
	@Id
    @JsonProperty("criteria_id")
    val id: UUID,
   
    @JsonProperty("tripType")
    val tripType: TripType,
    @JsonProperty("directTrainOnly")
    val directTrainOnly: Boolean,
)

@JsonProperty

Controller

@JsonProperty

@JsonProperty

@JsonProperty

Service

+ ORM

Serializer +

@Entity
@Table(name = "search")
data class Search(
    @Id
    @JsonProperty("id")
    val id: UUID,

    @OneToOne
    @JoinColumn(name = "criteria_id")
    val criteria: Criteria,
)

Criteria become an entity for the consumers!

Variant

CROSS-CONTAMINATION

Each grammar brings constraints of its own, tied to its dimension

challenges

A constraint from one expression may leak into a neighbor

Once it spreads to enough expressions, it becomes a structuring constraint

Rail Shopping

Rail Supply

UI

If

If

If

If

If

If

If

If

If

UI

Rail Shopping

Rail Supply

Feature Team

#1

Feature Team

#2

Feature Team

#3

UI

Rail Shopping

Rail Supply

PERMEABILITY

Solve a concern in the wrong expression, and cohesion erodes

challenges

The bounded context stops being always valid

THE SELECTION

Permeability

database

Model Expression

Backend

Model Expression

Model Expression

UI

Inventory

SElection

Inventory

SElection

TGV 9612

72€

TGV 9612

72€

selection

TGV 9612

72€

TGV 9612

72€

TGV 9612

72€

database

Model Expression

Backend

Model Expression

Model Expression

UI

Inventory

SElection

Inventory

SElection

TGV 9612

72€

TGV 9612

72€

selection

TGV 9612

72€

TGV 9612

72€

PUT

TGV 9612

72€

database

Model Expression

Backend

Model Expression

Model Expression

UI

Inventory

SElection

Inventory

SElection

TGV 9612

72€

TGV 9612

72€

selection

TGV 9612

72€

TGV 9612

72€

PUT

TGV 9612

72€

PRICE  CHECK

PRICE CHECK

database

Model Expression

Backend

Model Expression

Model Expression

UI

Inventory

SElection

Inventory

SElection

TGV 9612

96€

TGV 9612

72€

selection

TGV 9612

72€

PRICE  CHECK

TGV 9612

72€

TGV 9612

72€

database

Model Expression

Backend

Model Expression

Model Expression

UI

Inventory

SElection

Inventory

SElection

TGV 9612

96€

TGV 9612

72€

selection

TGV 9612

72€

PRICE  CHECK

TGV 9612

72€

PUT

TGV 9612

72€

database

Model Expression

Backend

Model Expression

Model Expression

UI

Inventory

SElection

Inventory

SElection

TGV 9612

96€

TGV 9612

72€

selection

TGV 9612

72€

PRICE  CHECK

TGV 9612

72€

PUT

It was solved in the wrong expression!

Name

Name

Name

Name

Date of Birth

Date of Birth

Date of Birth

Date of Birth

Date of Trip

Date of Trip

Date of Trip

Date of Trip

# Loyalty Card

# Loyalty Card

# Loyalty Card

# Loyalty Card

Payment Method

Payment Method

Payment Method

Payment Method

🏨
Hotel

Dossier

✈️

Flight

Dossier

🚗

Car

Dossier

🚆

Train

Dossier

Name

Name

Name

Name

Date of Birth

Date of Birth

Date of Birth

Date of Birth

Date of Trip

Date of Trip

Date of Trip

Date of Trip

# Loyalty Card

# Loyalty Card

# Loyalty Card

# Loyalty Card

Payment Method

Payment Method

Payment Method

Payment Method

🏨
Hotel

Dossier

✈️

Flight

Dossier

🚗

Car

Dossier

🚆

Train

Dossier

🔥METADOSSIER🔥

METADOSSIER EXCEPTION

🏨
Hotel

Dossier

✈️

Flight

Dossier

🚗

Car

Dossier

🚆

Train

Dossier

METADOSSIER?

🔥METADOSSIER🔥

◆ straight from the pain of the users ◆

Essential business concept

IMPEDANCE MISMATCH

When some model expressions do not preserve the homomorphism

challenges

heavy translation layers compensate for the lack of cohesion

Misalignments are not worked around,
and a business capability discrepancy occurs
 

"It's developers' misunderstanding, not domain experts' knowledge, that gets released in production."

— Alberto brandolini

The developer's mistake ships as the business experts' expertise

corollary

Feedback Loop

CONFUSING BOUNDED-CONTEXT

AND MODEL EXPRESSIONS

This leads to model tensions, eroding the conceptual integrity of the bounded-contexts

challenges

Sometimes bounded-contexts are considered to be model expressions

sometimes a model expression is overthought as a bounded-context

frontend

backend

"journeys": [ ... ],
"trains": [
  { 
    "number": "A", 
    "bound" : "OUTBOUND" 
  },
  { 
    "number": "B", 
    "bound" : "OUTBOUND" 
  }
],
"selection": {
  "OUTBOUND": { "trainNumber": "A" }
}
"legs": [ ... ],
"trains": [
  { 
    "number": "A",
    "bound": "OUTBOUND",
    "selected": true 
  },
  { 
    "number": "B",
    "bound": "OUTBOUND",
    "selected": false 
  },
]

2 models = 2 solutions to 1 problem

A MODEL IS A SOLUTION

confusing bounded-context & expression

frontend = bounded-context

Backend = bounded-context

frontend

backend

"journeys": [ ... ],
"trains": [
  { 
    "number": "A", 
    "bound" : "OUTBOUND" 
  },
  { 
    "number": "B", 
    "bound" : "OUTBOUND" 
  }
],
"selection": {
  "OUTBOUND": { "trainNumber": "A" }
}
"legs": [ ... ],
"trains": [
  { 
    "number": "A",
    "bound": "OUTBOUND",
    "selected": true 
  },
  { 
    "number": "B",
    "bound": "OUTBOUND",
    "selected": true 
  },
]

Invariant:
only 1 outbound selected

structurally safe!

BOUNDED-CONTEXT

BOUNDED-CONTEXT

frontend

"legs": [ ... ],
"trains": [
  { 
    "number": "A",
    "bound": "OUTBOUND",
    "selected": true 
  },
  { 
    "number": "B",
    "bound": "OUTBOUND",
    "selected": false 
  },
]

backend

"journeys": [ ... ],
"trains": [
  { 
    "number": "A", 
    "bound" : "OUTBOUND" 
  },
  { 
    "number": "B", 
    "bound" : "OUTBOUND" 
  }
],
"selection": {
  "OUTBOUND": { "trainNumber": "A" }
}

Compensating translation layer

BOUNDED-CONTEXT

BOUNDED-CONTEXT

frontend

backend

"journeys": [ ... ],
"trains": [
  { 
    "number": "A", 
    "bound" : "OUTBOUND" 
  },
  { 
    "number": "B", 
    "bound" : "OUTBOUND" 
  }
],
"selection": {
  "OUTBOUND": { "trainNumber": "A" }
}
"journeys": [ ... ],
"trains": [
  { 
    "number": "A", 
    "bound" : "OUTBOUND" 
  },
  { 
    "number": "B", 
    "bound" : "OUTBOUND" 
  }
],
"selection": {
  "OUTBOUND": { "trainNumber": "A" }
}

Model Expression

Model Expression

BOUNDED-CONTEXT

BOUNDED-CONTEXT

Preserving the conceptual integrity of model expressions

MODEL EXPRESSION,
OR BOUNDED-CONTEXT?

the question

FALSE COGNATE OR DUPLICATED CONCEPT?

HEURISTICS

same meaning? ◆ SAME STRUCTURE? ◆ SAME relationships? ◆ SAME types?

SAME

One Model

MODEL EXPRESSION

DIFFERENT

two Models

BOUNDED-CONTEXT

FRONTEND

SEARCH

BackEND

SEARCH

=

?

CHANGE A STRUCTURING CONSTRAINT

How does each one move?

HEURISTICS

  • Keep the same structure, relationships, names model expression

  • maps it to a  concept or false cognate  bounded-context

Domain code

Model Expression

Outbound / Inbound

List<Journey>

Model Expression

UI

2 fixed fields

Add-a-destination control

Model Expression

2 fixed journeys

List of journeys

user Story

database

Model Expression

Keyed by bound

Keyed by position

Move an invariant to another expression.
Any functional impact?

Impact → the always-valid model breaks → 2 different bounded contexts

INVARIANT MIGRATION

HEURISTICS

B

A

?

Round Trip

has 2 adjacent journeys!

No impact → just a delegation → 2 expressions of 1 bounded context

HOW DO WE KEEP ONE EXPRESSION FROM CONTAMINATING ANOTHER?

Each expression is limited by its grammar

The grammars differ, and that is normal

preserving conceptual integrity

CREATE TABLE criteria (
  search_id CHAR(36) PRIMARY KEY
    REFERENCES search(id),
  trip_type VARCHAR(16) NOT NULL,
  direct_train_only BOOLEAN
);
data class Criteria(
  val tripType: TripType,
  val directTrainOnly: Boolean,
)

VALUE OBJECT

Service

Persistence

entity

Model Expression

Model Expression

EXTENDING THE ANTICORRUPTION LAYER

preserving conceptual integrity

Service

Persistence

VALUE OBJECT

entity

Model Expression

Model Expression

Criteria

Criteria

AntiCorruption Layer

DROP CRITERIA_ID

HEXAGONAL ARCHITECTURE

preserving conceptual integrity

Only when the grammars allow these expressions to be isomorphic:
exactly the same

THE CONFORMIST STRATEGY

preserving conceptual integrity

@Entity
@Table(name = "criteria")
data class Criteria(
	@Id
    @JsonProperty("criteria_id")
    val id: UUID,
   
    @JsonProperty("tripType")
    val tripType: TripType,
    @JsonProperty("directTrainOnly")
    val directTrainOnly: Boolean,
)

A grammar limit is not a tension

A tension is stretching an expression beyond what its grammar requires

MODEL TENSION HEURISTICS

preserving conceptual integrity

A model tension is a design smell

SQL

Criteria
{ID}

Grammar limit

CODE

Criteria
{ID}

Model Tension

Sometimes there is no way to prevent contamination
ex: Conway's Law applies every time

DRIVING FORCES

preserving conceptual integrity

In a well-applied hexagonal architecture, the domain is the driving force

Don't fight a driving force. Steer it.
Inverse Conway Maneuver pushes the homomorphism your way

User

Billing

Payment

Shopping

User

Billing

Shopping

Payment

FINAL VERDICT

KEEP EVERY EXPRESSION
SPEAKING THE SAME MODEL

The model is intangible. We only ever deal with its expressions.


CASE CLOSED

◆ Tactical Variance Authority ◆

conclusion

@julientopcu.com

JULIEN TOPçu

SENIOR ARCHITECT INQUISITOR

◆ Tactical Variance Authority ◆

CTO

COACH

 

socio-technical architect

VARIANT

Three SQL workarounds for a value-object Criteria

StrategyCost
Inline in parentconcept gone
Inherited identity (shared PK)column name lies, bytes are a search_id

Every table requires a PRIMARY KEY. The grammar offers no neutral path.

Persistence

Tables

Surrogate Key Forger

ORM

@Id

@OneToOne

@JoinColumn

@Table

@Entity

@Entity
@Table(name = "search")
data class Search(
    @Id
    @JsonProperty("id")
    val id: UUID,

    @OneToOne
    @JoinColumn(name = "criteria_id")
    val criteria: Criteria,
)
@Entity
@Table(name = "criteria")
data class Criteria(
	@Id
    @JsonProperty("criteria_id")
    val id: UUID,
   
    @JsonProperty("tripType")
    val tripType: TripType,
    @JsonProperty("directTrainOnly")
    val directTrainOnly: Boolean,
)

Serializer

@JsonProperty

Controller

@JsonProperty

@JsonProperty

@JsonProperty

Service

+ ORM

Serializer +

The contamination travels upstream

A constraint that originated in SQL's grammar reshapes the nature of criteria

@Entity
  @Table(name = "criteria")
  @JsonIgnoreProperties(ignoreUnknown = true)
  @JsonInclude(JsonInclude.Include.NON_NULL)
  data class Criteria(
      @Id
      @GeneratedValue(strategy = GenerationType.UUID)
      @JsonProperty("criteriaId") // ← now the id ships on the wire too
      val id: UUID,

      @Enumerated(EnumType.STRING)
      @JsonProperty("tripType")
      val tripType: TripType,

      @JsonProperty("directTrainOnly")
      val directTrainOnly: Boolean,
)

Train carries a Schedule

data class Train(
    val number: String,
    val schedule: Schedule,
    // ...
)

data class Schedule(
    val departure: OffsetDateTime,
    val arrival: OffsetDateTime
) {
    init {
        require(arrival.isAfter(departure)) {
            "arrival cannot precede the departure"
        }
    }
}

arrival > departure, what could go wrong?

CREATE TABLE train (
    ...
    departure CHAR(25) NOT NULL,  -- '2026-10-25T02:30:00+02:00'
    arrival   CHAR(25) NOT NULL,  -- '2026-10-25T02:45:00+01:00'
    CHECK (arrival > departure)   -- looks fine
);

Looks fine. Compiles. Runs.

Autumn DST rollback

INSERT INTO train (..., departure, arrival, ...) VALUES (
    ...,
    '2026-10-25T02:30:00+02:00',  -- before rollback (CEST)
    '2026-10-25T02:30:00+01:00',  -- after rollback (CET)
    ...
);
-- ERROR: new row violates check constraint

> is lex on strings. Prefix matches up to the offset: '+02:00' > '+01:00', so the row is rejected.

The constraint lies twice a year.

Example

Business capability discrepancy due to duplication of business logic in the front-end.

Example

Aggregate IDs as database auto-increments instead of functional IDs leading to a violation of essential unicity constraints.

CREATE TABLE search (
      id			CHAR(36)     PRIMARY KEY,
      criteria_id	CHAR(36)	 NOT NULL UNIQUE REFERENCES criteria(id) ON DELETE CASCADE,
      selection_id	CHAR(36)	 REFERENCES selection(id),
      -- ...
  );

CREATE TABLE criteria (
    id          	   CHAR(36)     PRIMARY KEY,
    trip_type          VARCHAR(16)  NOT NULL,
    direct_train_only  BOOLEAN      NOT NULL,
    CHECK (trip_type IN ('ONE_WAY', 'ROUND_TRIP'))
);
idselection_idcriteria_id...
search#1selection#1criteria#1...

Search

idtrip_typedirect_train_only...
criteria#1ROUND_TRIPtrue...

Criteria

Criteria has its own identity and is independent from the Search!

Example

A presentation model expression using local date time, forcing the zoned persistence model expression to localize the dates to the Zulu timezone.

 On this last point, model expressions may follow the CAP theorem.

Example

Only considering front-ends as model expressions of their back-end's model, when they should be bounded-contexts (PWA, SPA), will produce distributed Big Balls of Mud.

Example

BFF (Back-end for Front-end) as a smell for a hidden bounded context.

Miscontextualizing meta models

A meta-model can be used broadly amongst different model expressions, even when it doesn't make sense.

Meta-models, Socio

Spotify, Team Topologies, SAFE, SCRUM...

Meta-models, Technical

Tactical patterns, CRUD, CQRS, LLM / neuronal models, FC / IS.

Example

REST is a CRUD meta-model suitable for system affordance, but it is not always suitable for a domain core, as it may make the latter anemic.