Back to Blog
5 min read

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.

Building a Multi-Service Logistics System with Node.js, Prisma, and TypeScript

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

  1. Prisma transactions are your friend — any operation that touches multiple tables should be wrapped in prisma.$transaction().

  2. Add database indexes early — I added a createdAt index on the Order table after noticing slow pagination queries in production.

  3. Type your events — shared TypeScript interfaces for Socket.IO events between server and client catch mismatches at compile time.

  4. 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.