Skip to content
Kumar Chandrachooda
.NET

The Database Is the Message Bus

Five executables, zero queues - how an attendance platform coordinated a web app, three Azure Functions and a console job through one SQL database, and what that choice bought and cost. Part 1 of the series.

By Kumar Chandrachooda 03 May 2025 8 min read
Five plugs, one bus bar - every executable wired into the same database

Draw five applications that need to coordinate and most architects will reach for a queue before the whiteboard marker is warm. The attendance platform I built at a mid-size IT services company has five executables, and not one of them ever sends another a message. Every watermark, every retry signal, every email template and every unit of pending work travels through the same Azure SQL database. The database is the message bus — and this series is an honest tour of what that architecture looks like from the inside, several years after shipping it.

Five executables, one database

The platform tracks attendance for employees across offices in three Indian metros. Badge gates record physical swipes; a work-from-home timer records virtual ones; shift allowances mean actual money rides on what the attendance record says. The estate that serves all of this:

Unit Kind Schedule Job
Web app ASP.NET Core MVC always on dashboards, WFH timer, report builder, Excel export
Employee sync Azure Functions v3 timer daily 00:00 UTC mirror the HR system's employee master
Leave sync Azure Functions v3 timer every 30 min mirror the leave system, rewrite affected reports
Swipe-out notifier Azure Functions v3 timer every 5 min email anyone virtually swiped in for over ten hours
Shift calculator console app external scheduler infer shifts from badge data, gap-fill since 2019

Five processes, one Azure SQL database, and no other channel between them. No service bus, no storage queue, no HTTP calls from one unit to another, no shared cache. The web application has a fifteen-part series of its own — An Attendance Platform in ASP.NET Core — so this series lives almost entirely in the hinterland behind it: the batch jobs, the schema, and the coordination patterns that grew between them.

If you have read anything about integration styles, you will recognise this as the shared database pattern — the one the literature files under “please don't”. I want to show you what it actually looks like when a small team does it anyway, because the details are far more instructive than the verdict.

The contract is a DbContext in a zip file

For a shared database to work at all, everyone has to agree what the tables mean. Here that agreement is enforced the bluntest way possible: every executable compiles against the same EF Core model, shipped as a NuGet package. Not from a feed — from a folder of .nupkg files committed into each repository:

attendance-leave-sync/
├── SharedDLL/
│   ├── Attendance.DataModel.1.4.2.nupkg
│   ├── Attendance.Azure.1.1.0.nupkg
│   └── Attendance.MonthlyReportGenerator.1.0.0.nupkg
├── src/
└── .gitlab-ci.yml     # dotnet nuget add source ./SharedDLL

The CI script points dotnet nuget add source at the folder and restores from it. It is a poor man's private feed, and it has real consequences — version drift between repos, a package whose source exists nowhere — that get a full article later in the series. For now the important observation is architectural: the AttendanceContext class is the integration contract. There is no OpenAPI document, no message schema, no versioned event definitions. There is a C# class library, copied five times, at whatever version each repo last vendored.

That sounds fragile, and it is. But notice what it quietly gets right: every consumer of the database goes through the same entity definitions, the same column mappings, the same conventions. When the schema changed, the package version bumped, and each repo adopted it deliberately. Plenty of shared-database estates are worse — five apps, five hand-written DALs, five slightly different opinions about what IsActive means.

Coordination is a table

Strip the queues out of an architecture and the coordination work does not disappear; it re-materialises as tables. Four of them carry almost all of it:

  • StatusSheet — every batch job writes a row per run: job name, result, message, a LastRecord watermark, elapsed time. It is simultaneously the job log, the incremental-sync watermark store and the metrics system. The leave sync literally reads its own last Success row to decide where to resume — part 3 is devoted to it.
  • Tolerance — a work queue. When something upstream needs a recompute, a row appears with an employee ID, a date to rewind to and an action name; the shift calculator consumes it and marks it Done. Cross-job signalling with rows, covered in part 8.
  • RawEmail — email subjects and HTML bodies stored as rows, with a #{Model.Property} placeholder mini-language and per-environment variants. The notifier function renders them with sixty lines of reflection.
  • MasterLookUp — a stringly-typed key-value table that maps things like leave types to shift IDs, int.Parsed at the point of use. Indirection for values that were too volatile to hard-code and too small to deserve a table each.

There is a genuine elegance to this that I will not pretend away. Everything is inspectable with a SELECT. There is no queue explorer to install, no dead-letter subresource to page through; when a sync misbehaved, one query against StatusSheet told you the last twenty runs, their durations and their error messages. Operations people who would never open Application Insights were entirely comfortable in SQL Server Management Studio.

The costs are just as real: no locking semantics, no visibility timeouts, no retries, no backpressure, and failure handling that amounts to “write a row and hope somebody reads it”. The series will weigh both sides properly in the retrospective.

Three schedulers, one of them imaginary

Timing in this estate is its own small comedy. The three Azure Functions run on timer triggers — daily, every thirty minutes, every five minutes. The console job is fired by an external scheduler that left no trace in the repo: no CI file, no trigger code, just a config value hinting it runs as a job. And then there is the third scheduler, the one that never existed at all.

The database carries a complete Hangfire schema — eleven tables of job storage, queues, servers and state — and there is not one line of Hangfire code anywhere in the estate. No AddHangfire, no dashboard, no recurring job registration, in any of the five executables. Someone installed the packages, let Hangfire create its tables, and then the plan changed; the code was removed or never written, but nobody drops schema they might need later. The tables sit there still, an architectural fossil of the scheduler that never arrived.

It is not the only absence. The badge gates write raw events into a Transactions table and a per-day aggregate into LogRecorder — and no executable in the estate writes either table. The ingestion component that fills them is a sixth executable that exists only as the tables it feeds; it was never part of the codebase I can read today. I flag both absences now because they shape everything downstream: the shift calculator consumes data whose producer is invisible to it, and the schema advertises capabilities nobody has. An estate map that omits its ghosts is a lie.

Where the series goes

Eighteen parts, one per Thursday. The first half follows the moving parts; the back half reads the schema they move across.

  1. The Database Is the Message Bus — this post.
  2. Syncing the Org Chart at Midnight — the daily HR mirror, and the bugs in its upsert.
  3. A Watermark Made of Your Own Success LogStatusSheet as log, watermark and metrics at once.
  4. When a Cancelled Leave Rewrites Last Month — retroactive reconciliation that respects human approvals.
  5. Guessing Your Shift with Manhattan Distance — the nearest-shift inference at the platform's heart.
  6. Gap-Filling Two Years of Attendance — sentinels, blank rows and the price of chatty writes.
  7. ModifiedBy Is a State Machine — provenance smuggled into an audit column.
  8. A Work Queue in a Table Named Tolerance — rerun signalling with rows.
  9. The Error Logger That Cannot Log — a confirmed bug: the error handler that throws.
  10. Nagging by Timer — the Ten-Hour Swipe-Out Email — the five-minute nag loop.
  11. An Attendance System Plays Timezone RouletteNow, UtcNow and IST.
  12. Two Dynamic-SQL Procs — One Injectable, One Correct — the same problem solved twice.
  13. Fourteen Foreign Keys and an Index Named Placeholder — integrity as a code of conduct.
  14. Org Charts in T-SQL, Three Ways — recursive CTEs versus scalar-function archaeology.
  15. The Payroll Month Starts on the Sixteenth — calendar maths the business insisted on.
  16. One Metric, Three Answers — the same KPI, three formulas, one dashboard tile.
  17. CI Theatre and a NuGet Feed Made of Folders — pipelines that test nothing.
  18. Five Executables, One Database — the Reckoning — the honest ledger for the whole estate.

If you have ever inherited a system where the jobs “just know” about each other, where the ops runbook starts with a SELECT, or where a schema contains tables nobody can explain — this estate is your estate, and the next seventeen parts are for you. We start where the data starts: the midnight sync that mirrors the org chart.