Skip to content
← Back to work

WaterTech

Two phone-first tools for a water treatment company's field reps: a site survey that prices a treatment program into a proposal, and a service visit that reconciles stock and permit samples into a report.

Role
Lead Engineer
Client work · US water treatment company
Period
2026
Status
In development

An internal tool for the client's field representatives, so there is no public instance. Happy to walk through the architecture.

4
Modules, each with its own schema
41
HTTP endpoints
2,400+
Automated tests
0
Calculation core dependencies

Overview

One Entra sign-in, one React SPA and one ASP.NET Core API on .NET 10 carry two tools, built as a modular monolith: four persisted modules, each with its own schema and DbContext, over a dependency-free calculation core in which every quantity carries its unit as a type. The survey tool captures boilers and cooling towers unit by unit, shows a live mass balance and chemical demand as the rep types, prices a program against a product catalogue designed to be fed from Power BI, drafts a proposal PDF with a generated executive summary, then files and emails it. The delivery tool counts the storeroom against deliveries, records discharge samples against permit limits, and submits a service report the same way.

The problem

The calculations lived in a reference workbook and a prototype, and every figure a rep produces ends up on a document a customer reads. For discharge permits, a regulator can read it too. That changes what failure means. A blank field silently read as zero, a checkbox that defaults to compliant, or a stored figure that went stale are all worse than an error, because each one prints a confident false statement.

Approach

  • 01A modular monolith with architecture tests as the enforcement, not a convention: no module references another, every entity lives in its module's schema, no foreign key crosses a schema, each context keeps its own migration history, and the migrations must match the model. A violation fails the build rather than waiting for a code review.
  • 02A calculation core with zero package or project references, enforced by a test. Every quantity is a unit type rather than a bare decimal, EF Core value converters are registered as conventions so a new quantity column maps itself, and precision is set per unit against the magnitudes the data reaches: cooling drift is 0.005 percent, which two decimal places round to 0.01 and double.
  • 03A live readout that recomputes on every keystroke from a verbatim TypeScript port of the engine, held to the .NET implementation by shared fixture parity tests. Screens import one calculation-source seam and never an engine. Where the delivery tool computes usage cost on both sides, both round with floor of the amount plus a half, because .NET rounds a half to even and JavaScript does not.
  • 04Deny-by-default authorization. The fallback policy composes two requirements, one proving the bearer token is an access token rather than an ID token, one checking security-group membership from claims, so an endpoint that declares nothing answers 401 instead of serving. Authenticated but not a member is 403 with no lookup. There is no development bypass.
  • 05Optimistic concurrency at the aggregate root and server-minted identifiers: a rowversion on the survey rather than the unit, because the operations that must conflict are survey-level, and identifiers minted from SQL sequences exactly once: a quote number at first draft, never rewritten, and a report number only at submit, since a sequence value is consumed when it is read.
  • 06A React 19 SPA under strict TypeScript with noUncheckedIndexedAccess and exactOptionalPropertyTypes, configured at runtime from one endpoint rather than a build-time env file, with a test that enforces it. The dose lookup is typed exhaustively, so adding a server enum value fails the typecheck instead of resolving to undefined, multiplying to NaN and printing as a dash on a proposal.
  • 07Infrastructure as code with private endpoints, Key Vault and managed identity, and a five-stage pipeline: what-if validation, infrastructure, build and test, migrations through a separate migrator, then the combined deploy. Bicep warnings fail CI, because one warning-level diagnostic once meant an apiVersion that did not exist.

Decisions & trade-offs

Refuse honestly when a dependency is missing

The API starts with no database, no Azure subscription and no tenant. Each optional dependency registers only when configured, and storage, filing and email fall back locally to a folder on disk. A deployed host gets no such fallback: App Service disk is ephemeral, so it would report success and lose the file on restart. It answers 503 instead. A store that loses writes is worse than one that admits it is missing.

Generated prose has to prove every number came from a fact

The executive summary is the only generated text. The prompt carries facts only, with no example to borrow numbers from, and a validator rejects any draft that states a number found in no fact or omits a required figure. Any failure renders a deterministic template. The catch is unconditional, because a list of expected failures once missed an authentication error and cost a rep the whole document. A hosted service warms the credential chain at startup: cold resolution measured 8.3 seconds against a 15-second budget.

A count nobody took is not a count of zero

Null means not measured, all the way to the printed page. Zero stock is a real fact that implies every delivered unit was used. Permit compliance has three states rather than a boolean, because a blank read as zero passes a ceiling and fails a band, so an unsampled parameter would print either a pass or a violation. The compliance attestation also defaults to unchecked. A default that attests to a discharge permit on a rep's behalf is an audit finding waiting to happen.

Store readings, snapshot inputs, never cache derived figures

A visit stores what the rep measured and nothing the tool computes: no cost, no treated volume, no compliance verdict, because a cached figure goes stale the moment a reading is corrected. Inputs are the opposite: a visit snapshots the product list and permit limits when it opens, so its report is generated from what is stored rather than from a shared constant that has since moved. Proposals stamp the engine version at first draft, never rewritten, so a later formula fix cannot change a proposal a customer already holds.

Pipeline order is behaviour

The storage and email unavailability handlers register above the global handler, because the chain stops at the first claim and a missing database would otherwise read as "the application is broken". Correlation ids are middleware rather than an action filter, so a response built after an exception or for an unmatched route still carries one. CORS runs before authentication, because a 401 written first never gets its origin header. A test pins the exception handler registration order.

Migrations run from a separate process

The API does not migrate at startup. An application that applies its own migrations needs elevated database permissions it should not hold in production, and nobody gets to read the DDL first. A separate migrator runs as its own pipeline stage, and the contexts it migrates are listed explicitly rather than discovered by reflection, so a new module declares itself in a diff. The cost is one more deployable and one more stage. The benefit is that the API never needs the right to change a schema.

What it does not do

  • It does not own the client's master data. Product and pricing data is designed to come from Power BI, and finished documents are filed into the client's own SharePoint.
  • The live readout does not call the server on every keystroke. It runs a TypeScript port of the calculation engine in the browser, held to the .NET implementation by shared fixture tests.
  • It is not a public product. It serves a small field team behind the client's Entra sign-in and is designed for phone browsers first.

Stack

.NET 10ASP.NET CoreEF CoreSQL ServerPower BIReact 19TypeScriptViteTanStack QueryZustandEntra ID (MSAL)Azure OpenAIMigraDocMicrosoft GraphAzure Communication ServicesKey VaultBicepAzure PipelinesxUnitVitest