Marine catering system rescue

2025 to now Enterprise system C# ASP.NET Core EF Core SQL Server Angular 19 Azure

A shipping company's entire catering department, as software. It starts at the catering contract on a vessel and ends at the invoice, and it carries its own purchasing, invoicing and accounting modules, because a catering department's version of those is not the one a general ERP ships. It came to me mid-build and stalled. I put the requirements in an order somebody could ship against, and it has run the department ever since.

The client and the product are not named, and there are no links. The system is described by its decisions instead, which is the part worth reading anyway.

The problem

  • One department, end to end: the catering contract per vessel, the food budget it is measured against, menu planning, the galley, buying, stock counts, the supplier invoice coming in and the client invoice going out.
  • It arrived with more ambition than sequence. A large feature surface, priorities that competed with each other, and no agreed order of delivery.
  • Underneath it, a database-first codebase: 230+ Entity Framework Core entities and 70+ stored procedures scaffolded over an existing SQL Server schema. Data access ran straight out of the controllers, so a write path and a transaction boundary had to be introduced around a system that was already live.
  • It had to keep running in production the whole time it was being fixed. A catering department does not get a quiet month.
  • The domain is unforgiving. Costs land late, in different currencies, from different ports, in different units of measure, on documents that arrive after the month has closed. Overspend against the victualling rate gets discovered rather than prevented, and the reconciliation it replaced was being done in spreadsheets.

Key decisions and tradeoffs

  • The backlog got sequenced before the code got touched. What the department needed in order to close this month, what could wait a quarter, and what was never going to earn its keep. Most of the value in the first weeks came from deciding what not to build, which is harder to put on a slide than a refactor.
  • Stabilise, do not rewrite. The schema and the 70+ stored procedures stayed, because the remaining-on-board and inventory maths genuinely belongs in SQL. They were isolated behind a second database context and every call standardised on parameterised FromSqlInterpolated, one consistent data-access pattern across all 700+ endpoints.
  • A service layer became the only write path, 100+ services on scoped lifetimes, with one explicit transaction pattern that also deletes uploaded blobs on rollback so storage never drifts from the database.
  • Fleet scope is resolved live from the user record, not from a token claim. A vessel reassignment takes effect on the next request instead of waiting for someone to log out. The resolver unions three conventions that all exist in the real org chart: vessels of an owner, vessels of a manager whose fleet spans several paper owners, and explicit per-user grants. A client who maps to nothing resolves to an empty fleet, never to all of it.
  • Permission is nine separate grants per role per module. One of them is view-without-prices, which strips prices out of generated Excel and PDF as well as off the screen, and fails closed when the user cannot be resolved. Purchase order approval is gated by amount band per role, so authority is a number rather than a job title.
  • The heaviest stock query was replaced behind a shadow compare. The old cursor-based procedure and the new set-based query ran side by side on live traffic, logging divergence, until the flag could be flipped on evidence rather than on a passing test suite.
  • The front end was rebuilt to Angular 19 with lazy-loaded feature modules and RxJS state instead of NgRx for the size of the team, upgraded in place rather than as a big-bang rewrite.

The catering domain

  • The catering contract per vessel: start and end dates, the value of the provisions taken over at the start and handed back at the end, trading zone and crew nationality, and an accounting period whose month is defined per vessel and does not have to agree with the calendar.
  • The victualling rate is the food budget per man per day. It is effective-dated with an approval and a full history, because a rate change is a fact with a date on it, not an overwrite. Cost per man-day is the number an executive gets measured on.
  • Man-days is the denominator everything else divides by, and it is not the crew list. It is the crew, plus superintendents, riding gangs and anyone else eating on board, plus the extra man-days recorded at month end. Get the denominator wrong and every rate in every report is wrong with it.
  • Menu planning has to reach provisioning: recipes exploded into item demand, cuisine driven by the crew's nationality, vegetarian and non-vegetarian counts per meal, rank, and per-vessel nutrition tolerances split by moderate and extreme working conditions. That last one is the shape a crew nutrition obligation takes under MLC 2006, and a system that only records it has not met it.
  • The galley gets its own working surface. Meals get swapped, items get substituted, and actual consumption is recorded against the plan. Plans always diverge, and software that only stores the plan measures nothing.
  • Provisions, bonded stores and general stores behave differently, price differently and report separately. Collapsing them into "stock" loses the only distinction the bond report exists for.
  • The buying spine: requisition, request for quotation, multi-vendor quote comparison with its own approvals, purchase order, goods received note, supplier invoice. Cash purchases at port sit alongside it for when the contracted supplier is not an option, priced against a port-wise catalogue and a baseline port, under vendor contracts scoped per port and per vessel. Vendor performance is kept as evaluation criteria and ratings rather than as the purchaser's memory.
  • Stock is valued on weighted average, not FIFO, and the distinction is load-bearing. A typed opening rate is honoured only for the first inventory at takeover; every count after that has to store the recomputed average, or the stored value quietly diverges from what the reports calculate and the gap compounds for months before anyone sees it.
  • Units are where this domain eats people. A stock quantity is a count of packages labelled with a base unit, menu and galley quantities are already base units, and prices are per package. Multiply the wrong one and the answer still looks plausible, which is worse than an error that looks like one.
  • Days of cover is measured in value rather than quantity, because value is the only thing that adds up across items in different units. The on-hand figure drifts predictably between counts, so the forecast subtracts that drift instead of believing the snapshot. Items substitute inside a category and not across it, which makes the useful number a demand-weighted percentile of run-outs rather than an average. Where there is no defensible number it returns nothing instead of a guess.
  • The client-facing catering invoice is priced off port calls, and the hard requirement there is stability. An invoice already issued must not move because a departure was reported late. That one constraint shapes the whole selection rule, and it is why the rule keys off what was true at period close rather than what is true now.
  • The point of all of it: overspend used to be found after the month closed. Showing it while the month is still open is worth more than any feature on the roadmap.

What's inside

  • Took over a stalled, database-first codebase and shipped it to production in about three months, then kept building.
  • Backend refactor across 90+ controllers and 100+ services, 700+ endpoints over 230+ Entity Framework Core entities and 70+ stored procedures.
  • Full front-end rebuild to Angular 19, 240+ components across 140+ routes in lazy-loaded feature modules, upgraded in place.
  • 60+ separately permissioned modules covering contract, victualling, menu planning, galley execution, purchasing, inventory, invoicing and accounting.
  • Multi-role access control scoped per user, per vessel and per vendor, with Save, Approve and Submit workflows across every document.
  • PDF and Excel generation, transactional Azure Blob storage with a local fallback, and Application Insights telemetry.
  • Stabilised the data layer, the stored procedures and the deployment pipeline, so releasing stopped being a ritual somebody had to be awake for.

Scale and failure modes

  • 90+ controllers, 100+ services, 700+ endpoints, 230+ entities, 70+ stored procedures, 80+ SQL migrations and 240+ Angular components, over 250,000 lines of first-party code, all still in production.
  • 1,500+ tests across the two codebases, including golden-replay tests that pin generated invoices to known output and a contract tripwire that fails when a stored procedure's result shape changes underneath the code reading it.
  • Document workflows, Draft, Pending, Approved, Finally Approved, enforced at both the controller and the service, never only in the UI.
  • Uploads are transactional: a failed save rolls back the blob as well as the row.
  • The dashboard cache has a per-key stampede guard, because one screen fans out into many requests for the same vessel set and period and used to reach the database as a crowd.
  • Documents render from one shared model, so the preview and the download cannot disagree. Fonts and map geometry are embedded in the assembly, so a deployment cannot silently lose them and produce a document that looks like a different company's.
  • Reporting for spreadsheet users runs through a read-only, transaction-free feed with its own published API description, kept away from the write path so an analyst refreshing a pivot table cannot contend with a purchase order being approved.
  • External callers authenticate against a key bound to a real user record, so an integration carries the same permission scope and audit trail a person would.
  • What was deliberately dropped: a vessel-to-office batch replication design, built for when a ship's link was too poor to work online. Connectivity improved, and carrying a second consistency model stopped being worth what it cost.