Inside the architecture of a modern marketplace
How we structured a freight marketplace around a ledger, a modular monolith and a matching engine that started as twenty lines of rules.
3 min read
Marketplaces look deceptively simple. There is supply, there is demand, and there is a list in between. The first version of almost every marketplace is a CRUD app with two kinds of users.
Then money arrives. A shipper pays a deposit, a trucker needs an advance for fuel, a load is delivered damaged, a payment is disputed. Very quickly, the marketplace stops being a listing site and becomes a financial system that happens to have a search problem attached.
This is the architecture we used for a freight marketplace connecting shippers and hauliers on the Douala–N'Djamena corridor, and the reasoning behind each piece.
One deployable, clear modules
We did not start with microservices, and we have not needed them. The system is a single deployable with strict internal modules: listings, matching, trips, ledger and notifications. Modules talk through explicit interfaces, own their own tables, and never reach into each other's data.
This gives us most of what people want from microservices — clear ownership, independent reasoning, the option to split later — without paying for distributed transactions on day one. For a team of six, that trade is not close.
The ledger is the source of truth
Every movement of money is a set of ledger entries that sum to zero. Balances are never stored and updated in place; they are derived from entries. Escrow is not a flag on a trip, it is an account.
create table ledger_entries (
id uuid primary key,
transaction uuid not null,
account text not null, -- e.g. 'shipper:812', 'escrow:trip:4410'
amount_xaf bigint not null, -- positive = debit, negative = credit
created_at timestamptz not null default now()
);
-- Every transaction must balance.
create constraint trigger ledger_balanced
after insert on ledger_entries deferrable initially deferred
for each row execute function assert_transaction_balances();
When a shipper funds a trip, money moves from their account into the trip's escrow account. When the load is delivered, it moves from escrow to the haulier, minus the platform fee, in one balanced transaction. Disputes freeze the escrow account rather than editing history. Every question finance asks can be answered with a query instead of an investigation.
Matching: rules first, models later
The matching engine began as a scoring function. It considers route overlap, vehicle capacity, the haulier's history on that corridor, and how long the load has waited:
export function scoreMatch(load: Load, truck: Truck): number {
if (truck.capacityKg < load.weightKg) return 0;
const route = routeOverlap(load.route, truck.plannedRoute); // 0..1
const reliability = truck.completedOnCorridor / (truck.completedOnCorridor + 3);
const urgency = Math.min(hoursSince(load.postedAt) / 48, 1);
return route * 0.5 + reliability * 0.3 + urgency * 0.2;
}
It is twenty lines, anyone on the team can explain a result, and operations staff can argue with it in plain language. We log every suggestion and whether it was accepted. When there is enough of that data, a learned model can replace the weights. Until then, a transparent rule beats an opaque model trained on nothing.
Events leave through an outbox
Notifications, analytics and partner integrations react to things that happened: a load was posted, a trip started, a payment settled. Publishing those events directly from request handlers risks announcing something that then rolls back.
Instead, each module writes events to an outbox table in the same transaction as its state change. A small relay publishes them afterwards and marks them sent. If the relay is down, events wait. If a transaction rolls back, its events never existed.
What we would do again
- Model money as a ledger from the first week. Retrofitting one is far harder than starting with one.
- Keep one deployable until something forces a split. Nothing has yet.
- Make matching explainable before making it clever.
- Treat "pending" as a normal state in every flow that touches a third party, and show it honestly in the UI.
None of these ideas are new. Their value is in applying them early, before the marketplace's first dispute makes the case for you.