LDX3 London  ·  June 2026

Your problem isn't the monolith.
It's the data.

Prem Chandrasekaran  ·  Tech Director, Thoughtworks
Pramod Sadalage  ·  Distinguished Engineer, Thoughtworks

Six years of monolith. Eighteen months of microservices.

Same engineers. Same data. Different outcome.

Architecture endures when teams align on data lifecycles, not just APIs.

Five disciplines · we'll walk all of them

01 Ownership 02 Contracts 03 CI fitness 04 Safe migrations 05 Decompose along seams

🙋Raise your hand if none of these are true in your ecosystem.

Rot doesn't start at the deployment boundary

It starts at the schema

🔓01

Writes to any table are allowed.

No persistence-layer gatekeeper.

🔗02

Cross-domain joins in hot paths.

Read paths span domains.

03

Retention rules vary across teams.

Per-team, per-table policies.

💬04

Shared database. Different vocabularies.

One schema, multiple consuming teams.

Penultimate Markets, 2022. One deployment. 120 engineers. 8 markets.

Identity & Accounts Catalog Orders Payments Tax Search Fulfillment Pricing & Promotions MONOLITH ONE DEPLOYMENT SYSTEM SCALE 1M orders/day 25M SKUs

01·Ownership

Aggregates own their invariants.

Order aggregate with internal entities and external references as IDs

01·Ownership

What this looks like in practice

Somewhere in the tax codebase…

ASKING


var order = orderRepo.findById(id);
if (order.state() != DRAFT)
    throw new IllegalStateException();

var subtotal = order.lines().stream()
    .map(l -> l.price().multiply(l.qty()))
    .reduce(ZERO, BigDecimal::add);

order.lines().forEach(l -> {
    var rate = rateFor(order.address());
    l.setTaxRate(rate);
    l.setTaxAmount(l.price().multiply(rate));
});

order.setSubtotal(subtotal);
order.setState(TAXED);
orderRepo.save(order);

TELLING


orderService.applyTaxes(
    orderId, taxBreakdown);

Tell, don't ask.

02·Contracts

Other teams consume contracts, not tables.

ORDER Internal tables PRIVATE TAX Local projection order lines shipping addresses ✕ DIRECT TABLE QUERY would couple Tax to Order's schema forever PUBLISHES EVENTS Owns the schema. Owns the contract. SUBSCRIBES TO EVENTS Builds its own copy. Owns its schema. EVENTS PUBLISHED BY ORDER OrderPlaced v3 OrderShipped v2

02·Contracts

The contract is the boundary.

INTERNAL · Order's private tables


-- private to orders-team
CREATE TABLE orders (
  order_id       UUID        PRIMARY KEY,
  customer_id    UUID        NOT NULL,
  state          order_state NOT NULL,
  placed_at      TIMESTAMPTZ NOT NULL,
  currency       CHAR(3)     NOT NULL,
  total_minor    BIGINT      NOT NULL,
  fraud_score    SMALLINT,        -- internal
  channel        TEXT,
  channel_meta   JSONB,           -- internal
  fulfillment_id UUID,
  ml_segment     TEXT,            -- internal
  updated_at     TIMESTAMPTZ NOT NULL
);
CREATE TABLE order_lines       ( ... );
CREATE TABLE order_status_log  ( ... );
CREATE INDEX ON orders (customer_id, placed_at DESC);

PUBLIC · OrderPlaced event


syntax = "proto3";
package penultimate.orders.events.v3;

// Owner: orders-team
// Compatibility: BACKWARD (Buf in CI)
message OrderPlaced {
  reserved 9, 10;   // removed: discount_code, promo_id

  string             order_id        = 1;
  string             customer_id     = 2;
  int64              placed_at_ms    = 3;
  string             currency        = 4;
  Address            shipping        = 5;
  repeated OrderLine lines           = 6;
  Money              total_amount    = 7;
  Source             source          = 8;
  optional string    idempotency_key = 11;
}
// internal-only fields excluded:
//   fraud_score, channel_meta, ml_segment

Versioned. Owned by orders-team. Published with a guarantee.

03·CI fitness

Discipline is not a wiki page.
It's CI exit code 1.

03·CI fitness

Fitness functions for data boundaries.


@AnalyzeClasses(packages = "com.penultimate")
public class DataBoundaryFitnessFunctions {

  @ArchTest
  static final ArchRule no_cross_domain_persistence_access =
    noClasses().that().resideInAPackage("..tax..")
      .should().accessClassesThat()
      .resideInAPackage("..orders.persistence..")
      .because("Tax reads OrderPlaced events. Tax does not query Order's tables.");

  @ArchTest
  static final ArchRule writes_only_via_aggregate_root =
    noClasses().that().resideOutsideOfPackage("..orders.domain..")
      .should().callMethod(OrderRepository.class, "save", Order.class)
      .because("Order's invariants live in Order. No exceptions.");

  @ArchTest
  static final ArchRule events_versioned_and_immutable =
    classes().that().areAnnotatedWith(DomainEvent.class)
      .should().haveSimpleNameEndingWith("V1")
      .orShould().haveSimpleNameEndingWith("V2")
      .andShould().haveOnlyFinalFields();
}

Three rules. One CI build. The four failure modes — blocked at the door.

03·CI fitness

Other fitness functions you'll add

1

Schema migration linter

Expand / contract enforcement.

2

Contract stability test

Pact, schema-registry compatibility.

3

Cross-aggregate join detector

SQL log analysis or query plan inspection.

4

Event schema validation

In CI, against registry.

5

Lifecycle / retention assertions

Per-table TTL declared, enforced.

+

your next one

04·Safe migrations

Migrations expand. Then contract.

Every step is reversible. None of them is a flag day.

01 Expand

Add the new column nullable. Dual-write from the app.

Drop the column. Old reads were never touched.

02 Backfill

Populate new column from old. Idempotent, restartable.

Re-run. Or pause and resume; the script is a fact, not a flag.

03 Switch reads

Reads move to the new column behind a feature flag.

Flip the flag back. Both columns still hold the truth.

04 Contract

Stop writing the old column. Drop it in a later deploy.

Only safe once no reader has touched old for a full retention window.

05·Decompose along seams

The monolith doesn't fail.
It stops fitting.

05·Decompose along seams

Three pressures. Three triggers.

Decomposition didn't start with a plan. It started with incidents.

Y5 · Q1

Catalog publish p99 hit 800 ms.

Search team needed regional read replicas — fast.

Y5 · Q3

Audit flagged tax record retention drift.

Tax team needed immutable, append-only storage — for seven years.

Y6 · Q1

Payments outage cascaded to checkout.

Payments team needed an isolated failure domain — and a stricter SLA.

05·Decompose along seams

The pressure was not traffic. It was lifecycle.

Search

Tax

Payments

Consistency

eventual
strong
strong

Latency

tight
relaxed
tight

Retention

short
7 years
medium

Isolation

regional
regulated
bulkheaded

→ Cache at the edge.

→ Append-only ledger.

→ Bulkhead for blast radius.

05·Decompose along seams

Same colors. Same domains. Same teams.

Before (Year 4)

Modular monolith at Year 4

After (Year 7)

Distributed architecture at Year 7

05·Decompose along seams

Aggregates become extraction boundaries.

Search subscribes to ProductPublished, OrderPlaced; in Year 4 in-process, in Year 5 over Kafka

05·Decompose along seams

Same contract. Bigger blast radius.

Read model contract growth from Year 4 to Year 7

05·Decompose along seams

Process managers replace transactions.

Order placement flow from single DB transaction to process manager

05·Decompose along seams

Duplication owned by a contract is not debt.
It's autonomy.

Tax keeps its own copy of order line items.
Search keeps its own denormalized catalog projection.
Both update from events; both own their schema.

In 2026

The cost compounds.

57%

Agent tools

over-expose data through naively converted APIs.

+322%

AI-assisted code

privilege-escalation paths. +153% design flaws.

Commit volume

more commits in fewer, larger PRs. Review attention didn't scale.

AgentRaft 2026  ·  Apiiro Fortune 50 telemetry 2025

AI doesn't change the principles.
It compounds the cost of getting them wrong.

Data Architecture · A Software Architect's Guide to Building Data-Driven Systems

Want the deep version?

Your Monday Morning Actions

1

Encode ownership.

Which team owns which aggregates? Write it down this week.

2

Publish contracts.

Events and read models, versioned and owned.

3

Verify in CI.

Fitness functions for boundaries, contracts, migrations.

4

Make migrations safe by design.

Expand, then contract. Always.

5

Decompose along seams when lifecycle pulls.

Not when traffic pulls. Not when fashion pulls.

Your monolith will stay modular longer.
Your decompositions will be evolutionary, not traumatic.

What that looked like at Penultimate

2/wk → 50/day
Deploy frequency
−68%
On-call escalations
~3 mo
Extraction → GA
0
Extraction-related outages

Year 4 → Year 7. Same headcount. Same teams.

Thank you.