Available · Ottawa, ON

I build enterprise systems
that hold up on the factory floor.

.NET Developer · Solutions Architect · Automation & AI Integration

I build agentic AI into reliable .NET products.

01 — Credentials

Clearances & recognition.

Award
Instant Recognition Award
Awarded
Awarded for outstanding performance and exceptional contribution to the team.
Mar 2025
Clearance
Reliability Clearance
Active
02 — About

Who I am.

I'm a .NET developer in Ottawa, and most of what I build has to keep working when nobody's watching it. My work has taken two paths that rarely cross. On one, I helped build a Government of Canada platform that manages more than a billion dollars in trust assets for Indigenous communities. On the other, I'm the only developer behind a manufacturing system that runs a foam plant around the clock, from the touchscreen an operator signs into down to the industrial printers it drives. These days most of my time goes to agentic AI, though I'm picky about what that means in practice: the agents I build show their evidence for every suggestion, and their limits are built into the architecture rather than requested in a prompt. They can recommend; they can't quietly act on their own. I tend to own a system end to end, from the first conversation about what it needs to be to the server I'm still running months later.

03 — Capabilities

What I work with.

Framework

5
ASP.NET Core Blazor EF Core React Microsoft Agent Framework

Language

4
C# TypeScript Python SQL

Tool

11
Agentic AI LLM Integration Prompt Engineering Power Automate AI Evaluation & Observability AI-Paired Development SQL Server Oracle Power BI Git Data Integration & ETL

Cloud

5
Azure OpenAI Azure App Services Azure Functions Azure DevOps Azure Storage

Industrial

8
ERP Integration TCP/IP Sockets GS1 Compliance Zebra ZPL MaintainX Power Platform Custom Connectors Fleet Telematics REST API Integration
04 — Selected Work

Projects I'm proud of.

MyAssistant - household operations platform Featured

MyAssistant - household operations platform

The app my household runs on, and the .NET 10 system I built solo to power it.

The problem

A shared household runs on a hundred small obligations. Whose turn is it to take out the bins. Did anyone pay the internet bill. What's left in the grocery budget this month. Why is there a $40 charge on the bank statement nobody recognizes.

Normally this stuff is scattered across group chats, a fridge whiteboard, two or three half-used apps, and whoever happens to remember. Splitting a bill turns into mental arithmetic. "Whose turn" turns into a quiet argument. I wanted one shared place that holds all of it, where the money math and the rotation logic are handled for you instead of relitigated every week.

Show details ▾Hide details ▴

What it does

It puts a household's day-to-day in one shared place. People in the same household get recurring tasks with a rotation that tracks whose turn is next, bills with line items and percentage splits that settle themselves, budgets funded from a personal wallet through a running ledger, and shopping lists with an in-store mode that groups items by aisle for the actual trip.

The feature people react to most: you upload a bank statement PDF and the app reads it. It pulls out the account, the statement period, every transaction, and a category for each one, then checks its own arithmetic before saving anything. Receipts work the same way from a photo.

Everything shared updates live across devices. There's a notification feed, an activity timeline, system-wide search, five switchable dashboards, an admin console, and a public demo anyone can open without signing up.

Under the hood

I built it as a .NET 10 Clean Architecture solution where dependencies only ever point inward. The Domain layer has no idea EF Core or ASP.NET exist, and that's enforced by the project references, not by good intentions. Application sits on top with CQRS handlers, Infrastructure implements the interfaces, and the API stays thin.

A few decisions hold the whole thing together:

  • Expected failures return a Result<T> instead of throwing. A missing bill comes back as NotFound, a broken business rule as Invalid, and a controller extension maps each state to the right HTTP status. Exceptions are reserved for the genuinely unexpected.
  • Only aggregate roots get repositories, and the rule is a generic type constraint, so asking for a repository over a child entity won't compile.
  • Query logic lives in 173 named specification classes, never in the repositories or the handlers. BillByIdSpec knows how to load a bill with its lines and splits; the repository just runs it.
  • Domain events are recorded inside entities as they change, but they fire only after the database commit succeeds, dispatched by an EF SaveChanges interceptor. A failed save fires nothing.
  • Cross-cutting concerns are wired exactly once. Audit logging, the soft-delete query filter, exception capture, and validation all live in shared infrastructure, so every new feature inherits them for free.

Realtime is convention-driven. A startup scan registers every aggregate root, and the SignalR hub authorizes a subscription by calling the same service method the REST endpoint uses, so the two can't drift apart. Plain CRUD broadcasts a change event automatically from that same post-commit interceptor, and a push failure can never roll back a write.

Budgets don't store a balance anywhere. Every figure is folded on demand from immutable ledger entries, so the history is the source of truth and a correction is just another entry. The statement importer validates before it persists: it hashes the file, checks two unique indexes to reject duplicate uploads, and only then spends money on the LLM call.

The AI layer

This is the part I'd point an AI engineer at. Five features are backed by OpenAI: bank-statement extraction, receipt parsing from a photo, free-text recipe parsing, grouping a shopping list by store aisle, and an admin tool that explains production exceptions in plain language. The plumbing around them matters more than the prompts.

  • Structured output is guaranteed, not hoped for. A small generator reflects over a C# record and emits a strict JSON Schema, which goes to OpenAI in strict mode, so the bank-statement response deserializes into a typed object every time.
  • Every call goes through one gateway that records the model, token counts, estimated dollar cost, latency, status, and a preview of the prompt and response. Admins can audit exactly what the app asked and what it cost.
  • The demo account can't drain my OpenAI budget. The gateway counts a demo user's calls since the last reset and, once they hit the cap, throws before the HTTP call ever fires; a MediatR pipeline behavior turns that into a clean 400 instead of a 500.
  • The receipt reader checks itself. It asks the model for line items, verifies they sum to the printed total, and when they don't, it sends the discrepancy back and asks again, up to three attempts.

Built solo, by the numbers

I designed and wrote every layer myself, browser through database:

  • 7-project Clean Architecture solution, roughly 118,000 lines of C# (about 68,000 once you exclude generated migrations)
  • 30 REST controllers exposing 208 endpoints
  • 203 CQRS handlers guarded by 94 validators
  • 173 query specifications, 36 domain entities, 58 domain events
  • 13 background services handling funding, reminders, alerts, and file cleanup
  • 284 Blazor WebAssembly components and pages
  • 1,154 unit tests
  • 5 OAuth providers and 5 AI-powered features

A representative problem

One bug is worth telling because it shows the kind of thing that eats real days. Completing a shared task started returning 500, but only sometimes, and only when a second person completed it. The completion itself had already saved. The cause sat three layers away: a child entity assigns its own Guid in its constructor, EF Core read that non-empty Guid as proof the row already existed, and when a notification appended a new actor it issued an UPDATE against a row that was never there. That threw inside a domain-event handler, poisoned the request's shared database context, and surfaced as a 500 on a completely unrelated endpoint. The fix is one line per child configuration. Finding it meant tracing a failure backward through the event dispatcher, the change tracker, and the request scope, then writing it up as a standing rule so the next entity can't repeat it.

Tech stack

  • Backend: .NET 10, ASP.NET Core, EF Core 10 on SQL Server, MediatR, FluentValidation, Ardalis.Result and Ardalis.Specification, Mapperly for compile-time mapping, Scrutor for assembly scanning.
  • Realtime and auth: SignalR, ASP.NET Identity with JWT, and OAuth through Google, Microsoft, Apple, Facebook, and GitHub.
  • Frontend: Blazor WebAssembly with MudBlazor.
  • AI and documents: OpenAI over a resilient HttpClient, PdfPig for statement text, Magick.NET for images.
  • Other: RestSharp for Mailgun email and currency rates, WebPush for browser notifications, Swagger, and xUnit with Moq for tests.

Results

It's live, and anyone can use it. You can register an account, or open the demo with one click and walk through tasks, bills, budgets, shopping, and a sample bank import without signing up. The demo resets itself, so explore freely. My own household uses it as our daily driver for chores, shared bills, and the grocery run, which is the test I care about most: software my family relies on every day rather than a screenshot in a repo.

The button below opens the landing page, where "Try the demo" gets you straight in.

C# .NET 10 ASP.NET Core Clean Architecture Domain-Driven Design CQRS MediatR Event-Driven Architecture Modular Monolith Result Pattern Specification Pattern Repository Pattern EF Core SQL Server Background Services Blazor WebAssembly MudBlazor PWA SignalR Real-time Agentic AI OpenAI LLM Structured Outputs Multimodal AI OCR Prompt Engineering PdfPig ASP.NET Identity JWT OAuth REST API Design API Versioning Swagger/OpenAPI FluentValidation Mapperly
2025
Portfolio + AI Career Assistant Featured

Portfolio + AI Career Assistant

It looks like an ordinary developer portfolio. Behind the admin login is a multi-stage AI agent that turns one job posting into a researched, fact-checked, ATS-scored resume and two cover letters. The fact-checker is deliberately blinded to the posting, so it can only verify claims against my real history.

The problem

One job posting sets off dozens of small decisions. What shape the resume should take. Which experiences lead. Which keywords the ATS is scanning for. And the quiet one nobody admits to: do I stretch this claim, just a little. You pay that tax every time you apply: copy, paste, edit, second-guess, repeat. The writing was never the hard part. The hard part is that nothing checks your work. Nothing tells you whether the impressive line you just wrote actually lives somewhere in your own history, or whether you talked yourself into it because the posting wanted to hear it.

Show details ▾Hide details ▴

What it does

You give it a job posting. It gives back a researched, fact-checked resume and two cover-letter variants, tailored to that posting and scored against its ATS keywords.

Between those two points sit several stages, each doing one job under its own JSON schema:

  • Company research runs a web search over the employer's site, recent news, and culture signals, then caches the dossier for thirty days so the next run against the same company is free.
  • Job analysis hands the posting and my profile to a reasoning model, which scores fit from 0 to 100 and returns severity-ranked gaps, matched strengths, and a keyword-frequency map.
  • Generation drafts the resume and two cover letters in different strategic angles, bound by anti-fabrication rules and a list of banned openers, and steerable by coaching notes I type in.
  • A humanizer pass rewrites against a saved sample of my own writing and audits itself for the tells of machine text.
  • Then the part below: fact-check, critic, and grade.

Government of Canada postings take a different branch, which I'll get to.

The fact-checking pass

Before any document reaches me, a dedicated o3 stage reads every claim in the resume and cover letters and checks it against my raw profile, and nothing else.

The design choice I care about is what this stage is not allowed to see. Its instructions are explicit: it does not get the job analysis or the company dossier, only my own history. That blindness is the whole point. A fact-checker that can see the posting will quietly rationalize a stretch, because the posting makes the stretch sound reasonable. Cut off from it, the model has no story to lean on. All it can ask is whether the claim shows up in my profile.

It returns a verdict on each claim, supported, partial, embellished, fabricated, or unverifiable, with the exact profile line that backs it, plus a list of everything that needs revising. A second model then rewrites only the flagged spots; everything else comes back untouched, and if nothing was flagged it never runs at all. The studio shows me which claims are grounded and which are not, so I see the receipts before I send anything.

One honest limit: this catches what the grounding stage flags. If that stage misjudges a claim, the critic won't save me, because there's no second independent judge behind it. It fact-checks every claim against my profile and flags the ungrounded ones. It does not promise zero fabrication.

Observability and graceful degradation

Every model call in the pipeline goes through one gateway, and every call gets logged: the stage, the model version I asked for and the one OpenAI actually ran, the full prompts including the cached prefix, input and output tokens, cached and reasoning tokens, latency, and whether it succeeded.

That log isn't a dashboard nicety. It's load-bearing for how the system fails. Each log writes on its own dependency-injection scope and its own DbContext, separate from the request, so it commits immediately and a failed log write can never corrupt the pipeline's change tracker. When OpenAI hands back an empty response, the gateway records the failure and throws an error that reads "try regenerating" instead of a stack trace.

The payoff is that I can regenerate one piece at a time. If the resume draft times out, I retry just the resume, or a single bullet, or one project description, or one cover-letter variant, or a single merit-criteria answer, and only that field changes. Every edit I made elsewhere stays put. A slow stage becomes a button I press again, not a run I lose.

Auto-grading and output validation

Every finished document gets a grade. Five weighted measures roll into it: keyword coverage and required-skill match at 25% each, then voice, grounded-claim percentage, and nice-to-have coverage. Voice is scored mechanically, not by feel: the score starts at 100 and loses points for em-dashes, for words off a banned AI-vocabulary list, and for sentences that are all the same length. Format safety is a separate pass-or-fail check, not part of the weighted score: bullets stay under two lines, the summary stays under four sentences. The total lands on a letter grade, A+ at ninety-five and down through the B range to C at sixty, with anything lower an F.

The point is that I see a document's weak spots before I send it, not after. A regenerated resume that comes back a C tells me so instead of looking finished. Government applications run through the same grader, with merit-criteria coverage standing in for the usual checks and the grounded-claim percentage carried straight over from the fact-checker.

Under the hood

  • It's one ASP.NET Core Razor Pages app on .NET 10, with a single SQL Server database behind both halves: the public portfolio and the assistant.
  • Every model call returns structured output. Each stage's response is shaped by a closed JSON schema, unknown fields rejected and required fields enforced, validated as it deserializes into a typed C# object, so no stage parses free text.
  • Two model tiers, split by job. o3 at high reasoning effort handles the judgment, job analysis and grounding. gpt-5.4 handles the rest: company research, drafting, the humanizer, the critic, and the government stages.
  • Prompt caching is built in. A helper pads the static prefix, my profile and the rules, past OpenAI's roughly 1,024-token cache threshold, then appends the per-run content after a delimiter, and the cache hits show up in the per-call token counts.
  • Company dossiers are cached for thirty days, so researching the same employer twice in a month doesn't bill twice.
  • Resumes and cover letters render to print-ready PDFs through QuestPDF, in IBM Plex with a shared theme.
  • The contact form uses anti-forgery and post-redirect-get. xUnit tests drive the Razor page models directly against an in-memory database and run the whole pipeline end to end against a fake model client with canned responses.

The Government of Canada track

Federal postings get their own branch, switched on by a single flag on the application. A classification-research stage web-searches the Government of Canada standard for the position's group and level, so the writing matches the scope and seniority the classification actually implies. The pipeline pulls in my security clearances, their level, status, and dates, and a cached profile of the classification.

From the posting it pulls out the Statement of Merit Criteria, writes a STAR story for each essential and asset qualification, grounds and repairs each one against my profile in its own fact-checking pass, and humanizes it. The cover letters switch to angles that suit the public service. I built this once. The next federal posting just flips the flag.

Agentic AI LLM Engineering OpenAI Prompt Engineering Reasoning Models (o3) Structured Outputs AI Grounding & Hallucination Mitigation AI Evaluation & Observability C# / .NET ASP.NET Core
2025

Ready to build something great?

Federal enterprise platforms or shop-floor automation — let's talk about what you're building.

Start a Conversation