An Attendance Platform in ASP.NET Core
Badge gates, WFH timers and shift allowances - the shape of a seven-project intranet attendance platform, the onion it almost is, and the roadmap for fourteen more parts.
A badge gate is a very confident witness. It will tell you, to the second, that a particular card crossed a particular door — and nothing else. Everything a company actually wants from attendance — who worked, from where, for how long, on which shift, and whether that shift pays an allowance — is inference stacked on top of that one fact. And the inference has to survive people who forget to badge out, work from home, swap shifts at short notice, and take leave that gets cancelled after the month has closed.
This series is about an attendance platform I built at a mid-size IT services company: a web application serving employees across three Indian metros, sitting in front of badge-gate data, a work-from-home timer, manager approvals, a drag-and-drop report builder and a steady stream of Excel exports. It ran in production for years and did its job. It is also, in hindsight, a catalogue of the decisions a small team makes under deadline — some of them good, some of them instructive in the way only shipped mistakes are. I am going to walk through both kinds honestly, because the gap between what the code does and what we believed it did is the most useful material I own.
The stakes were higher than “intranet app” suggests. Night and early-morning shifts paid an allowance, so an attendance row was not a statistic — it was money. Managers approved rows; HR froze months for payroll; a wrongly-inferred shift was a wrongly-paid person. That single fact shaped everything: the approval workflows, the retroactive corrections, and the anxiety.
Seven projects, one intent each
The solution held seven projects, and their names tell you most of the architecture (namespaces here, as everywhere in this series, are illustrative stand-ins — the code samples throughout are freshly written for these articles, never lifted from the original repositories):
| Project | Role |
|---|---|
Attendance.Web |
The ASP.NET Core MVC host — twelve controllers, roughly sixty Razor views, SSO wiring, dashboards, exports |
Attendance.BusinessEntity |
About forty POCO DTOs and view-models; its only dependency is a JSON library |
Attendance.DataAccess |
Where the real business logic lives — repositories, a hand-rolled SQL builder, the export queries |
Attendance.DataModel |
The EF Core AttendanceContext, thirty-plus entities, twenty migrations, and a stored-procedure invocation framework |
Attendance.EmailService |
A Gmail-API sender, an SMTP sender, and a sixty-line reflection template engine |
Attendance.Azure |
One class that bridges two generations of Key Vault SDK |
Attendance.Database |
The SSDT project — tables, thirty-plus stored procedures, functions, views, seed scripts |
The dependency arrows are tidy: Web → DataAccess → {BusinessEntity, DataModel, EmailService}, with Azure as a shared leaf. Draw it on a whiteboard and you get a clean four-tier onion — presentation, business, data, entities. The whiteboard version of this architecture is true the way a job title is true: it describes the intent, not the day.
The onion, and the knife marks on it
The violations are where the teaching starts, and I want to name them up front because later parts return to each one.
Business logic leaked upward into controllers. The payroll month at this company ran from the sixteenth of one month to the fifteenth of the next, and the date arithmetic for that window was written out — independently, by hand — in at least four places: the employee controller, a shared helper, a repository, and the export controller. Four copies of the same rule is not redundancy; it is four opportunities to disagree.
Composition leaked downward past the container. The Startup class registered services properly in some lines and then, a few lines later, built objects by hand:
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<AttendanceContext>(
options => options.UseSqlServer(Configuration.GetConnectionString("Attendance")),
contextLifetime: ServiceLifetime.Transient);
services.AddScoped<IEmployeeData, EmployeeData>();
// composed by hand, against the grain of the container
var accountsContext = new AttendanceContext(BuildOptions(Configuration));
services.AddSingleton(new AttendanceAccounts(accountsContext));
}
Read those registrations slowly, because each one is a small story.
ServiceLifetime.Transienton the DbContext means two repositories inside one request never share a unit of work. Save through one, query through the other, and you can read your own write — or not — depending on which context flushed. Scoped is the default for a reason.- The hand-built singleton holds a single
AttendanceContextfor the process lifetime, quietly contradicting the transient registration one line above. The same type had two lifetimes, chosen by whoever touched the file last. - Repositories internally did
new SpRepository(dbContext)rather than taking an interface, so the seams the interfaces promised were never actually open. One controller action even constructed its own context inline, mid-request — a story I will tell properly in part eleven.
None of this stopped the platform working. That is precisely the trap: layering violations do not throw exceptions. They collect interest.
Targets 3.0, builds on 3.1
Every project file in the solution declared netcoreapp3.0 as its target framework. The CI pipeline built on a 3.1 SDK image. The EF Core packages were 3.1.1.
That combination compiles without a warning and deploys without complaint — the 3.1 SDK is perfectly happy to build a 3.0 target — which is exactly why it survived. The app claimed a runtime that had left support (3.0's support ended in early 2020; 3.1 was the LTS everyone meant to be on), while its dependencies had already moved on. Nothing in the build chain has an opinion about the gap. A target framework moniker is a claim, and nothing in a green pipeline checks whether it is the claim you meant to make. The fix was a one-line edit per csproj; the lesson is that version drift is invisible precisely because it is not an error.
It is worth saying that the web application is only the estate's public face: behind it sat three Azure Functions, a scheduled console job, and the SQL database they all coordinated through — a hinterland with its own companion series, The Database Is the Message Bus.
Where the series goes
Fifteen parts, one seam each:
- An Attendance Platform in ASP.NET Core — this post.
- Logging In with CAS When Everyone Uses OAuth — a 2004-vintage SSO protocol, and roles derived from the database at ticket time.
- Compiling Authorization Out of Debug Builds —
#if !DEBUGaround[Authorize], hard-coded identities, and an impersonation list in production config. - Resurrecting Html.Action in ASP.NET Core — re-implementing MVC5 child actions, and the
.Resultat the heart of 118 call sites. - A DB-First Model in Code-First Clothing — surrogate versus natural keys, scaffolded index cruft, and a
DbSet<List<string>>. - Three Ways to Call a Stored Procedure — reflection-built
EXEC, an interpolated TVF, and a raw ADO.NET runner. - The Multi-Result-Set Dream EF Core Wouldn't Grant — the abandoned EF6 framework shipped inside an EF Core app.
- A Report Builder Users Drag and Drop — self-service reporting, and the SQL builder that parameterised values but glued identifiers.
- Every Intranet App Ends Up Exporting Excel — EPPlus by reflection, a ten-byte corrupt file, and a licensing footnote.
- Sticky Sessions, Wrong Cache — in-memory session across three instances, and the cache expiry that throws every December.
- A Timer You Can Stop from an Email — the WFH state machine and the estate's one deliberate IDOR guard.
- Email Templates Live in the Database — seeded HTML, a sixty-line template language, and
string.Formatover raw CSS. - Two Key Vault SDKs, One Password in Plain Sight — three secret postures and a migration frozen mid-flight.
- The Dependency Graveyard — the packages that were referenced and never called, and what they cost anyway.
- What a Hand-Rolled Intranet App Taught Me — the honest retrospective.
If your controllers carry #if !DEBUG around their authorisation attributes, if your payroll month lives in four files, or if your csproj references a library nobody can find a call to — the next fourteen parts are for you. Next: the login page with no password box, Logging In with CAS When Everyone Uses OAuth.