---
title: Marine catering system rescue
slug: marine-catering-system
author: Charis Nikolaidis
year: 2025 to now
type: Enterprise system
tech: [C#, ASP.NET Core, EF Core, SQL Server, Angular 19, Azure]
url: https://charisnikolaidis.com/projects/marine-catering-system/
---

# Marine catering system rescue

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.

**Type:** Enterprise system · **Period:** 2025 to now
**Stack:** C#, ASP.NET Core, EF Core, SQL Server, Angular 19, Azure

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.

## Related

- Profile: <https://charisnikolaidis.com/llms.txt>
- All projects: <https://charisnikolaidis.com/projects/>
- Queryable over MCP: `https://charisnikolaidis.com/mcp`
