/projects/ulbra-atende
← All projectsUlbra Atende
IT service desk for a university, replacing GLPI.
Design & implementation · Professional work

Overview
The IT service desk for ULBRA — a .NET 10 modular monolith that replaced GLPI as the single intake channel for the university's IT department, carrying a request from ticket to SLA to satisfaction survey.
What I did
Principal author, from scratch — the architecture, the backend, the front end, and the deployment.
- The modular monolith and the boundaries between its contexts.
- The SLA engine, including pauses that record who stopped the clock and why.
- The transactional outbox and the notification fan-out it feeds.
- The OAuth authorization server and the MCP server behind its consent screen.
- The React front end and the Docker Swarm deployment.
The problem
ULBRA's IT department took requests through GLPI, e-mail, and direct messages at the same time. There was no SLA per team, no audit trail on approvals, and no way to tell whether anyone was satisfied with the outcome. Ulbra Atende replaces GLPI as the single intake channel and makes each of those measurable — three months in, the median ticket closes in about an hour and a half.
By the numbers
in ~3 months of production
Architecture
A .NET 10 modular monolith: one deployable, separate bounded contexts — Core, Identity, Notifications and MCP — each layered Domain → Application → Infrastructure with its own Postgres schema. Integration events travel over RabbitMQ through an EF transactional outbox. Attachments live in S3/MinIO, caching in Redis, tracing via OpenTelemetry; integration tests run against real Postgres, RabbitMQ and MinIO through Testcontainers.
- React 19 SPATanStack Router and Query over a Tailwind design system.
- .NET 10 APIModular monolith — four bounded contexts in one deployable.
- PostgreSQL 17One schema per module; EF Core migrations applied on startup.
- RabbitMQIntegration events published through an EF transactional outbox.
- Slack · Google Chat · e-mailNotification fan-out consuming those events.
The life of a ticket
The SLA clock is the thread running through it. It starts on the receiving team’s policy, stops when the ticket is waiting on someone outside the team, and is what the response and resolution targets are measured against. A ticket can also end cancelled, and work needing sign-off waits on an approval before it starts.
- OpenThe clock starts against the receiving team’s SLA policy, and triage routes it to a team and a category.
- InProgressAn assignee owns it. First response is already measured by this point.
- PausedWaiting on the requester or a third party. The clock stops, and who paused it and why is recorded as its own entry.
- CompletedThe work is done and the requester is asked to rate it — which is where the satisfaction score comes from.
What it does
- SLA per team, with pauses that record who paused the clock and why.
- Multi-stage ticket templates, so a recurring request arrives already broken into steps.
- Approval flow — work that needs a sign-off cannot start without one.
- Parent/child tickets and explicit dependencies between them.
- Notifications fan out to Slack, Google Chat and e-mail, per user preference.
- A dashboard whose cards drill down into the exact listing they summarize.
- A satisfaction survey on every closed ticket.
Engineering decisions
A modular monolith, not microservices
One team, one deploy. The boundary that matters is the module — enforced by project references and a schema per context — not the network. Distributing it would have bought deployment independence nobody needed and paid for it in latency, partial failures, and debugging.
A transactional outbox for every integration event
The event row is written in the same transaction as the business change. A notification can never fire for a ticket that failed to commit, and never disappears because the broker happened to be down at that moment — the relay delivers it once the transaction lands.
Strongly-typed IDs from a source generator
Every entity has its own ID struct, rendered as ti_…, tm_…, us_…. Passing a team ID where a ticket ID belongs stops compiling. A whole class of bug moves from runtime to build time, and IDs say what they are in logs and URLs.
Its own OAuth server, and an MCP server behind it
OpenIddict issues the tokens; the MCP server exposes ticket read/write and lookup tools. Someone connects Claude or ChatGPT to their own account through a consent screen and works tickets in natural language — under exactly the permissions they already have in the UI, with the same scope check on every tool call.