Building a Multi-Service Logistics System with Node.js, Prisma, and TypeScript
Architecture decisions, data modeling, and real-time sync patterns I used when building a 5PL construction logistics platform from the ground up.
BatailLog is a logistics management system I built for a French 5PL construction company. Multi-service architecture, real-time sync, complex relationships between companies, orders, inventory, and construction sites. Here's how I approached it.
What 5PL Actually Means
A 5PL (fifth-party logistics) provider manages the entire supply chain on behalf of their clients — they don't own warehouses or trucks, they orchestrate other providers. That means the data model is relationship-heavy: the platform must track which supplier delivers to which site, through which transporter, under which contract.
High-Level Architecture
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Companies │ │ Orders │ │ Inventory │
│ Service │────▶│ Service │────▶│ Service │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌──────┴──────┐
│ Shared │
│ PostgreSQL │
└─────────────┘
│
┌──────┴──────┐
│ Socket.IO │
│ + Redis │
└─────────────┘
Each service is an Express app with its own Prisma client pointed at the same PostgreSQL database (schema separation via Postgres schemas). They communicate over HTTP for request-response and Redis pub/sub for events.
Data Model
The trickiest part was modeling company relationships. A company can be a supplier, a client, a transporter, or any combination:
model Company {
id String @id @default(cuid())
name String
type CompanyType[]
// A company can supply to many clients
suppliesTo CompanyRelation[] @relation("supplier")
// A company can receive from many suppliers
suppliedBy CompanyRelation[] @relation("client")
orders Order[]
sites ConstructionSite[]
}
model CompanyRelation {
id String @id @default(cuid())
supplierId String
clientId String
active Boolean @default(true)
supplier Company @relation("supplier", fields: [supplierId], references: [id])
client Company @relation("client", fields: [clientId], references: [id])
@@unique([supplierId, clientId])
}
model Order {
id String @id @default(cuid())
reference String @unique
status OrderStatus @default(PENDING)
companyId String
siteId String
supplierId String
company Company @relation(fields: [companyId], references: [id])
site ConstructionSite @relation(fields: [siteId], references: [id])
items OrderItem[]
history OrderHistory[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OrderHistory {
id String @id @default(cuid())
orderId String
status OrderStatus
note String?
userId String
order Order @relation(fields: [orderId], references: [id])
createdAt DateTime @default(now())
}Inventory Tracking Pattern
Inventory was the most complex piece. Construction sites have inventory that changes as materials arrive and get consumed. I used a ledger pattern instead of mutable stock counts:
// Instead of: UPDATE inventory SET quantity = quantity - 5
// Use events that build up to current state:
async function consumeMaterial(
siteId: string,
materialId: string,
quantity: number,
reason: string
) {
return prisma.inventoryLedger.create({
data: {
siteId,
materialId,
type: "CONSUMPTION",
quantity: -quantity, // negative = outflow
reason,
recordedAt: new Date(),
}
})
}
// Current stock = sum of all ledger entries
async function getStockLevel(siteId: string, materialId: string) {
const result = await prisma.inventoryLedger.aggregate({
where: { siteId, materialId },
_sum: { quantity: true },
})
return result._sum.quantity ?? 0
}This gives a full audit trail and makes it trivial to compute stock at any point in time — critical for reconciliation.
TypeScript Strict Mode
Enabling strict: true across the monorepo caught dozens of bugs before they reached production:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}noUncheckedIndexedAccess was the most valuable: array[i] returns T | undefined, forcing you to handle the case where the element doesn't exist.
Lessons Learned
-
Prisma transactions are your friend — any operation that touches multiple tables should be wrapped in
prisma.$transaction(). -
Add database indexes early — I added a
createdAtindex on theOrdertable after noticing slow pagination queries in production. -
Type your events — shared TypeScript interfaces for Socket.IO events between server and client catch mismatches at compile time.
-
Soft delete everything — in logistics, data is never truly deleted. Add
deletedAt DateTime?to every model from the start.
Building this system taught me more about data modeling than anything else I've worked on.