Return to notes
Engineering leadership· 13 min read

Building EITAMS: Eight Months, a Real Client, and 43 Findings I Didn't See Coming

What I learned building an enterprise IT asset management system for TIQRI Corporation — the problem we got wrong at first, the decisions that mattered, and the five-hour code review that changed how I write software.

#Software engineering#Case study#Next.js#Code review#Lessons learned

Eight months ago, five of us were handed a real problem by a real company, and I made the same assumption almost everyone makes.

I assumed we were building an asset register.

We were not. TIQRI already had one. And understanding why that mattered turned out to be the most important thing that happened on the project — more important than any framework choice, and more important than most of the code I wrote afterwards.

This is a long post. It covers what we actually built, the decisions I'd defend, the ones I'd change, and the day four senior engineers read our codebase line by line and found forty-three things wrong with it.

The problem was not the one on the brief

Our second-year software project paired us with TIQRI Corporation as an external client. They wanted a system to manage their IT assets — laptops, monitors, software licences, office equipment.

In the first elicitation session, we walked through what they already had. It had single sign-on. It had categories and models. Serial numbers were locked after creation and could only be changed by an administrator. It had an audit log that recorded who changed what, with before-and-after values.

The product owner described it in three words: "just a basic system."

That sentence stalled me for about a week. If they already had a working register, what exactly were we for?

The three complaints they raised were specific: there was no dashboard, you could not see who was currently signed in, and there was no approval step on status changes. Individually those look like a feature list. Grouped with the business problems raised in the workshop, they turn out to be symptoms of one thing.

The existing system was built to store records. It was not built to govern how records change.

It answered "what is this asset?" very well. It could not answer "who authorised this being disposed of", "what is this asset worth today", or "did the person it's assigned to ever actually confirm they have it?"

That distinction — a catalogue versus a management system — became the single idea behind everything we built. And I only reached it because we spent time understanding what already existed instead of rushing to replace it.

If I take one thing from this project into every future one: the requirement you're given is rarely the problem you're solving. The gap between the two is where all the useful work is.

What we built

EITAMS covers an asset from purchase to disposal. Rather than list features, here are the parts where the design decisions are actually visible.

The lifecycle is a partition, not a dropdown

An asset in our system has a status: Available, Assigned, In Repair, Lost, Defective, Retired, Pending Disposal, Disposed.

The obvious implementation is a dropdown and a save button. We didn't do that, because the client's complaint about "no approval step on status changes" was really a complaint about consequence-free state changes.

So the status vocabulary is split in two.

Four statuses — Assigned, In Repair, Pending Disposal, Disposed — cannot be set by hand at all. They are not in the dropdown. The only way an asset reaches Disposed is by completing the disposal workflow with its approvals and attestations. The only way it reaches In Repair is by dispatching a repair.

The rest — Available, Lost, Defective, Retired — an administrator can set directly, with a reason recorded, because they carry no compliance consequence.

export const MANUAL_OVERRIDE_STATUSES = [
  ASSET_STATUSES.AVAILABLE, ASSET_STATUSES.LOST,
  ASSET_STATUSES.DEFECTIVE, ASSET_STATUSES.RETIRED,
];

export const WORKFLOW_GATED_STATUSES = [
  ASSET_STATUSES.ASSIGNED, ASSET_STATUSES.IN_REPAIR,
  ASSET_STATUSES.DISPOSED, ASSET_STATUSES.PENDING_DISPOSAL,
] as const;

That is the whole enforcement mechanism, and its smallness is the point. It is weaker than a full transition matrix — it doesn't constrain movement between the manual statuses — and we made that trade deliberately, because administrators can define their own custom statuses at runtime and a matrix over a set that grows at runtime becomes an administrative burden of exactly the kind we were trying to remove.

It preserves the guarantee that actually mattered. Nobody types their way to "Disposed".

Custody is confirmed, not asserted

The old arrangement recorded a user against an asset. Nobody ever confirmed it.

In EITAMS, assignment is a two-party handshake. An operator creates the assignment, which moves the asset to Assigned and puts the assignment record into a pending state. The employee then has to acknowledge receipt — or reject it, if the wrong machine turned up.

This is a small feature that took a disproportionate amount of design conversation, and I'd defend it against anything else in the system. Until acknowledgement, the register holds an assumption. After it, it holds evidence. Eighteen months later, when a laptop can't be found, the difference between those two things is the entire conversation.

It also catches errors at the only moment they're cheap — the person who notices IT issued the wrong machine is the person who opened the box.

Assets define their own shape

A laptop needs a processor, memory and screen size. A software licence needs a key, a seat count and an expiry date. A desk needs neither.

We could have added columns. Instead, a category carries its own attribute schema, and the registration form generates itself from that schema at render time. Three levels: the category defines what a class of asset needs, the model holds what's true of that product, and the asset holds what's true of that specific unit.

The result is that adding a new asset class is a data operation an administrator performs, not a schema migration and a deployment. In most systems of this kind, that's a change request with a lead time.

The financial layer

This is where a register becomes a management system. The old one recorded what an asset cost. It never calculated anything from it.

We built straight-line depreciation with residual value, a total-cost-of-ownership view that folds in every repair the asset has ever needed, and a salvage ledger that records book value against realised value at disposal so gain or loss is a stored fact rather than something recomputed later from inputs that may have changed.

Currency was more interesting than expected. We store both the native amount and the exchange rate in force at the time of purchase, and aggregate queries multiply through by the stored rate rather than a current one. So a report you run today over a purchase from last year uses last year's rate — which is what a financial auditor expects, and what you'd get wrong if you naively converted at read time.

One formula, two languages

The depreciation calculation lives in one file, deliberately isolated, because the method is a business rule that organisations revisit.

But it exists twice: once as a TypeScript function for per-record computation, and once as a generated SQL fragment for aggregate queries. Computing portfolio-level book value by fetching every asset into the application and reducing over it does not scale, so the arithmetic gets pushed into the database.

I'll be honest about this one: it is the most dangerous code in the system. Two expressions of one formula can diverge silently. Change the TypeScript and forget the SQL, and an asset's detail page reports one book value while the portfolio summary reports another, with nothing raising an error anywhere.

We have three controls — both live in the same file, both read the same constants, and the file header documents the change procedure. None of them is a test. The right answer is a property test generating inputs across the parameter space and asserting the two agree, and it doesn't exist yet. It's on the list.

The constraints that came from the platform

We deployed serverless, which means there is no always-on process. That single hosting decision reached into the architecture at three removes:

  • No resident scheduler. The nightly alert job has nowhere to live, so the schedule moved outside the application and calls back in over HTTP with a signed request the app verifies before doing anything.
  • No self-hosted WebSocket server. Real-time updates go through a managed publish–subscribe broker instead.
  • A connection budget. Because that broker's concurrent connections are finite, we spent them on the one interaction that's worthless when delayed — the mobile scanner — and used polling for everything else.

I did not anticipate any of that at the start. Infrastructure choices are architectural choices; they just present the bill later.

A bug I'm oddly fond of

Our alert engine can legitimately run twice against the same condition — a warranty expiring tomorrow is still expiring tomorrow on the next run. Without protection, that produces duplicate notifications.

We first tried to check for duplicates in application code and got it subtly wrong, because two runs can overlap and both pass the check before either writes.

The fix was a uniqueness constraint in the database, which made the enqueue operation idempotent by construction rather than by vigilance. Migration 0002_atomic_notification_queue_dedup.

The lesson generalises further than the bug: if correctness depends on two things not happening at the same time, don't enforce it in the layer where two things can happen at the same time.

Then four engineers read our code

By July we had a working system. Continuous integration was green. Twenty-three epics were done. We had demonstrated it to stakeholders more than a dozen times.

Then Supun, Anjalo, Samadhi and Hasitha — four TIQRI engineers — sat with us for five hours and went through the codebase line by line.

They found forty-three things.

Some were style. Some were architecture. And three, in particular, I want to write down publicly, because the value of this post is in these rather than in the parts where we looked good.

A deactivated account could still reach the API. Our edge middleware excluded the /api tree, and the shared authentication helpers returned a user object even when the isActive flag was false. So a disabled administrator could keep calling server actions and API routes. On mobile, where tokens last thirty days, that window was substantial.

Disposal certificates were in public storage. Every upload folder was written with access: 'public', and our file-type validation accepted a file if either the client-supplied MIME type or the filename extension matched. Invoices, warranty documents and destruction certificates were retrievable by anyone who had or guessed a URL.

QR pairing tokens weren't consumed atomically. The mobile pairing flow read the token from Redis and deleted it in two separate operations. Two concurrent exchanges could read the same one-time token and mint two device credentials from one photographed QR code.

All three are fixed. Principals are now reloaded from the database and inactive users rejected across every authentication path. Documents moved to private storage with an authenticated streaming endpoint and magic-byte verification. Token consumption uses an atomic GETDEL with an authoritative role check before signing.

We closed twenty-six findings that same day. Thirteen are reduced but need follow-up work. Four need decisions that aren't ours to make — things like which webhook destinations an organisation is willing to permit.

Here's the part that actually changed how I think.

None of those three defects would ever have been caught by our tests. Not because our tests were bad — we have over two hundred test files, they run on every push, and they cover the logic well. They'd have caught a broken calculation immediately.

But a test verifies a case its author thought of. Every one of those findings was a case none of us had thought of. And a demonstration is worse still, because a demo exercises the paths the demonstrator chooses.

It takes somebody who did not write the code to ask why a line is there at all.

If you are working on something alone, or in a small team where everyone shares the same assumptions, that gap is invisible to you by definition. Getting someone outside it to read your work is not a nice-to-have. On this project it was the difference between a system I thought was sound and one I have some justified confidence in.

What we also fixed, in one afternoon

The same review turned up a performance trace that was genuinely embarrassing.

Our registry pages were doing duplicate work: the browser re-requesting data the server shell had already supplied, and count queries running sequentially before row queries instead of together. Edge authorisation, which I'd assumed was the culprit, was healthy the whole time at 0–9 milliseconds.

After restructuring — returning count and rows in one query, reusing the server-rendered first page, consolidating asset detail tabs into a single action, deduplicating assignment overdue writes — the database work on the hardware registry went from 419 ms to 80 ms, software from 282 ms to 90 ms, and the assignment dashboard from 171 ms to 97 ms.

Not one of those improvements was a clever optimisation. All of them were the removal of work that never needed doing. That is usually what performance work is, and I'd have found it months earlier if I'd looked at a timing trace even once.

What still isn't finished

I'd rather say this than have someone find it.

End-to-end tests. We have a complete harness — database setup and teardown, seeded users, page objects, three browser engines, its own CI workflow — and only one scenario written against it. The unit and integration layers are solid. The full journeys are not automated yet. A change that broke a workflow while leaving every function individually correct would pass our entire suite.

It has never run on the client's infrastructure. Every measurement above is from our own environment.

So: validated, ready for a pilot, and not something I'd call production-ready. I think saying that plainly is worth more than the alternative.

Four things I'd tell myself in November

Understand what already exists before you design a replacement. A week spent working out that TIQRI's real gap was governance rather than storage was worth more than any week of implementation.

Put the quality gates in before the code they govern. We added CI properly partway through. The review found a failing test suite, 752 unformatted files and a migration missing from the committed chain — a deployment to a clean database would have failed. Every one of those was fixed within a day of a gate existing. They only accumulated because nothing rejected them.

Get someone outside your team to read your code, early, and make it easy for them to be harsh. Five hours produced more genuine improvement than any comparable stretch of the project.

Enforce a rule in the layer where it can actually hold. The duplicate notification bug, the atomic token consumption, the audit trail immutability question — three versions of the same lesson, and I needed all three before it stuck.

The stack, for anyone who wants it

Next.js and React with TypeScript throughout; PostgreSQL with Drizzle ORM; Keycloak over OpenID Connect for identity, so the application stores no passwords and can be pointed at a corporate directory with a configuration change; React Native and Expo for the mobile companion; managed services for scheduling, real-time delivery, object storage and email; Docker; Vitest and Playwright; GitHub Actions.

Twenty-three epics, 104 user stories, 67 functional requirements, 28 database tables, roughly 1,200 commits across two repositories, and five people over eight months.


Thanks to Thushara, Bala, Nadeesha, Anjalo, Sandun, Supun, Samadhi and Hasitha at TIQRI Corporation, who gave us eight months of Friday mornings and one very long Friday afternoon. And to my teammates, who were good people to build something with.

If you're working on something similar, or you disagree with any of the decisions above, I'd genuinely like to hear it — I'm reachable through the links on this site.

Continue reading

How to integrate the Spotify Web API with Next.js