George GiosueLet's talk
Blog

A generator-set quoting tool: five services and one SAP integration

Configuring a generator set is not picking a product off a catalog. It is combining an engine, an alternator, a coupling, a control panel and a long list of options, where every combination has its own derating, its own price and its own compatibility constraints. The project’s starting point was a set of spreadsheets.

The brief was to replace them with an application. It ended up as five services, a 108-table database and an SAP integration that took longer than everything else put together.

Why five services and not one

I did not set out to split anything. Each separation answered a concrete reason, almost always that something could not live alongside the rest.

The backend is the application: Express on Bun, MySQL, Redis for rate limiting and caching, Socket.io for live notices. The business logic lives there, spread today across 41 controllers.

The frontend is a React SPA on Vite. PrimeReact for the dense tables and forms a commercial tool demands, Tailwind for everything else, TanStack Query for server state and Zustand for the little local state that remains. It is translated, because the scope included exports.

The importer absorbs the catalogs. It is a separate service in Elysia on Bun that does one thing only: take Excel and leave it in the database in a usable shape.

The ERP gateway is PHP. It is the only piece that speaks SOAP, and it is in PHP precisely for that: its SOAP client gives the least friction with the descriptors SAP exposes.

The mobile app is Android with Kotlin and Compose, for salespeople in the field.

Everything runs in containers behind a reverse proxy that routes by labels, rather than by a central configuration that has to be touched on every deploy.

Quoting tool architecture Clients come in through the proxy; the backend concentrates the logic and is the only thing that talks to the importer, the gateway and the data layer.

The catalogs arrived pivoted

The engineering data came in the way engineering maintains it: sheets designed for a person to read. Headers across two rows, merged cells, the same brand written several ways, units implied by the column name.

None of that goes into a relational table without translation. And it was not a one-off load: catalogs get updated.

That is why the importer is a service and not a function inside the backend. Processing a large Excel file takes time, and that work should not compete with quoting requests in the same process. It is organized by the kind of data it ingests — accessories, combinations, technical data — with an orchestrator that coordinates the order, because combinations depend on the components already existing.

The decision that saved the most later was not aborting on the first error. A file with four thousand rows and twelve bad ones should load the 3,988 and return a report on the twelve, with row number and reason. If it fails wholesale, whoever maintains the catalog has no way of knowing what to fix.

Catalog upload The upload reports progress while it processes, instead of leaving the request hanging.

The ERP integration

This is the part I underestimated.

The ERP was the source of truth for customers, stock and orders, and the integration went over SOAP services: query and create customers, get stock, validate line items, create orders.

The problem was not SOAP. It was that the descriptors define the shape of the messages, not their meaning. What each code represents, which fields are mandatory in practice and what the system returns when something goes wrong are not things that live in a WSDL. In integrations with mature management systems this is the norm, not the exception.

I learned it through failed calls:

  • Dates travelled in one format and numbers had the comma and the period swapped relative to mine.
  • An empty field arrived sometimes as an empty string, sometimes as null and sometimes absent. All three meant the same thing.
  • The code catalogs did not match across environments, so something validated could behave differently once promoted.
  • The error messages were internal ERP codes, with no context on which field had caused them.

Three things ended up working, none of them brilliant:

A single translating layer. The gateway is the only thing that knows the ERP exists. It converts to its formats on the way out and normalizes on the way in. The backend makes ordinary REST requests and gets JSON back. When one of the services changed version, only one place had to be touched.

Logging the full exchange. Request and response, in both directions. It takes space, but it was the only way to reconstruct what had happened when something failed in production and could not be reproduced.

Validating the response before believing it. With Zod schemas, so I found out at the edge of the system and with a clear message, instead of three layers deeper with an undefined value.

I also collected a set of requests with real cases, successes and errors. The mocks I wrote at the start were too optimistic: they always returned the pretty response. Actual captured responses are the ones that teach you something.

Quoting, end to end

Quote creation form Creating a quote.

The salesperson builds a configuration and the frontend sends it to the backend. There the schema is validated, valid combinations are resolved against the catalog, deratings are applied according to the installation site conditions, and the price is calculated with the options and the customer’s commercial terms.

If stock has to be checked or the customer is new, the gateway steps in. The quote is saved with its line items and recorded in an audit log: who, when, what changed. I added that table at the start almost out of habit and it became one of the most queried parts of the system, both for debugging and for answering questions about something from months back.

The same technical data that feeds the configurator is published as datasheets that can be viewed without signing in. It came out of a simple request — that a salesperson could send a datasheet to a customer without attaching a PDF — and since the data was already normalized, exposing it was cheap.

Public datasheets Public datasheets, generated from the same catalog.

What I would do differently

Put the translation layer in before writing anything else. I started calling the ERP from wherever it was needed and centralized afterwards. Centralizing later cost more than starting that way would have.

Feature flags from day one. With five services, shipping something half-finished forces you to coordinate deploys. With a feature flag, the code can be deployed and switched off, and releasing stops being an event.

Write decisions down when you make them. The architecture document arrived late and we had to reconstruct from memory why certain things had been done. The field mappings in particular were knowledge that existed only in the code.

Distrust “it’s a simple query”. Every request that arrived described as simple — look up a customer, generate a PDF, import an Excel — turned out to be among the longest parts. Not out of bad faith: the complexity was in details that only appear once you implement.

What held up

Three things never needed revisiting.

Separating each service for a concrete reason: the importer for load, the gateway for SOAP, the app for platform. When the reason for the cut is specific, the boundary holds on its own.

Keeping the legacy in one place. The XML, the formats and the opaque codes live only in the gateway; the rest of the system does not know the ERP exists.

Logging from day one. The audit log and the gateway logs are what turned impossible-to-reproduce incidents into solvable ones.


Services

Service Stack
Frontend React, Vite, Tailwind, PrimeReact, TanStack Query, Zustand, i18next
Backend Express on Bun, MySQL, Redis, Socket.io, Zod, Winston, S3
Importer Elysia on Bun, Excel processing
ERP gateway PHP, SOAP client, REST adapter
Mobile Kotlin, Jetpack Compose

Infrastructure: Docker and Traefik. Testing: Vitest and Playwright on the frontend, Vitest and Supertest on the backend.