Overview
CredosisApi is the backend that runs Credosis’s public-facing meeting scheduler and the internal operations console behind it. Visitors browse open time slots and book a meeting in a couple of clicks — similar to Calendly — and the system takes care of everything that has to happen behind that click: reserving the seat safely under concurrent load, creating a Google Calendar event with a Google Meet link, and sending branded confirmation emails to both the visitor and the team.
On the other side, an authenticated admin dashboard lets the team define weekly availability, generate concrete bookable slots, manage bookings (reschedule and cancel), triage contact-form submissions, watch email deliverability, and read a full audit trail of every privileged action.
The goal was a small, dependable service with no moving parts that surprise you in production — where a third-party outage degrades gracefully instead of taking down a booking, and where every state change is observable after the fact.
The Problem
Credosis needed a self-hosted booking backend rather than a SaaS scheduler, for three reasons:
- Control over the booking experience. The scheduling UI is part of the marketing site, so the booking API had to integrate with a custom frontend rather than an embedded third-party widget.
- Real calendar integration. Every booking needs to land on the team’s Google Calendar with a working Google Meet link — automatically, with no manual copy-paste.
- Reliable communication. Confirmation, reschedule, and cancellation emails can’t be “best effort and hope.” A dropped confirmation email is a missed meeting.
Layered on top of scheduling, the business also needed contact management, analytics, and an audit trail — the operational plumbing that turns a booking endpoint into a usable internal tool.
Architecture
The service follows a layered architecture with strict dependencies pointing inward, which keeps business rules independent of framework and infrastructure concerns.
Presentation → Controllers, request/response DTOs, validation, exception handling
Business → Services (use cases), domain exceptions, interfaces
Data → EF Core DbContext, entities, configurations, repositories, migrations
Infrastructure→ Google Calendar, email (Brevo/SMTP), options, background workers
Common → Cross-cutting: time zone clock, pagination, rate-limit policies
A few conventions hold the codebase together:
- Interface-first services. Every service is defined by an interface in the
Business layer and registered through dependency injection, so controllers
depend on abstractions and the wiring lives in one place (
Program.cs). - Repository pattern over EF Core. Data access goes through repositories, which keeps query logic testable and out of the service methods, while still letting services own transactions when a use case spans multiple writes.
- DTOs at the edge. Entities never leak past the Business layer; requests and responses are explicit records validated with FluentValidation.
- Problem Details everywhere. A global exception handler maps domain
exceptions (
NotFoundException,ConflictException,ValidationException) to RFC 7807 responses, so the frontend gets consistent, typed errors.
Key Modules & Solutions
Meeting booking system
The scheduling core has three layers of its own: availability rules → generated slots → bookings.
Admins define weekly availability rules (day of week, start/end time, slot
duration, capacity). A slot generator then materializes those rules into
concrete, bookable MeetingSlot rows for a requested date range. Keeping
generation explicit — an admin action rather than a side effect of a public read
— means the public GET endpoints stay side-effect free and slots never
silently reappear after cleanup.
// Read-only public slot listing: slots are created explicitly via the admin
// "generate" endpoint, never as a side effect of a public read.
var effectiveStart = request.Start < now ? now : request.Start;
var slots = await _slotRepository.GetAvailableAsync(
effectiveStart, request.End, cancellationToken);
return slots
.Where(x => x.AvailableSpots > 0)
.Select(/* map to response */)
.ToList();
The booking lifecycle covers book → reschedule → cancel, and each transition keeps the slot’s seat count, the calendar event, and the notification emails in sync.
Concurrency-safe booking
The most interesting correctness problem here is the classic one: two people booking the last seat at the same time. The system solves it with an optimistic concurrency token plus a database transaction.
await using var transaction = await _context.Database
.BeginTransactionAsync(cancellationToken);
var slot = await _context.MeetingSlots
.FirstAsync(x => x.Id == request.MeetingSlotId && x.IsActive, ct);
if (slot.AvailableSpots <= 0)
throw new InvalidOperationException("This meeting slot is fully booked.");
slot.BookedCount++;
slot.Version++; // optimistic concurrency token
// ... create the booking, then SaveChanges + Commit
If a competing transaction commits first, SaveChanges throws a
DbUpdateConcurrencyException, which is caught and turned into a friendly
“try another slot” message rather than a 500. A unique constraint also prevents
the same visitor from double-booking the same slot, and that violation is
translated into its own clear error.
The result: under a race, exactly one booking wins, the loser gets an actionable message, and the seat count never goes negative.
Google Calendar & Meet integration
Every booking creates a Google Calendar event with an auto-generated Meet link, the visitor added as an attendee. The integration authenticates with the OAuth2 installed-app refresh-token flow, so the service holds a long-lived refresh token and transparently mints access tokens as they expire — no interactive login in production.
The defining design decision is that the whole integration is best-effort:
public async Task<CalendarEventResult> CreateEventAsync(/* ... */)
{
if (!IsEnabled) return default; // inert no-op when unconfigured
try
{
// ... build event with a Meet conference request, insert it
return new CalendarEventResult(created.Id, meetingUrl);
}
catch (Exception ex)
{
// Never let a calendar failure break the booking.
_logger.LogError(ex, "Failed to create Calendar event; booking continues.");
return default;
}
}
A Google outage, a network blip, or missing configuration can never fail a visitor’s booking — worst case, the booking is saved without a Meet link. On reschedule the event is patched (preserving the Meet link and attendees); on cancel it’s deleted. Crucially, those calendar calls happen after the database transaction commits, so a slow Google response never holds a DB lock or blocks the user’s request path.
Email & the transactional outbox
Email reliability is handled with the transactional outbox pattern. Services
never call the email provider inline. Instead they enqueue a message, which is
persisted to an OutboxMessage table in the same unit of work as the booking. A
background OutboxProcessor then picks up pending messages and delivers them
with retry and backoff.
This buys two guarantees:
- No lost emails. If the process crashes right after a booking, the queued email is already durably stored and will be sent on the next processor run.
- No phantom emails. If the booking transaction rolls back, the outbox row rolls back with it, so an email is never sent for a booking that didn’t happen.
Delivery goes through the Brevo HTTP API (port 443) rather than SMTP,
because many hosts — including Render’s free tier — block outbound SMTP ports. A
full SmtpEmailSender remains in the codebase as a drop-in fallback transport.
Every message type has a branded HTML template built on a shared layout:
booking confirmation, reschedule, cancellation, and the matching admin
notifications, plus a contact-form notification and admin-composed one-off
emails with attachments.
Contact management
Contact-form submissions are captured as Contact entities with a status
lifecycle (new → contacted → resolved), an admin notification email on arrival,
and paginated, filterable admin endpoints for triage. It’s deliberately simple,
but it rounds out the “operations console” story so the team doesn’t need a
second tool for inbound leads.
Audit logging
Every privileged action — creating an availability rule, generating slots,
cancelling or rescheduling a booking, changing a contact’s status — is written
to an append-only audit log via an IAuditLogger, which uses the HTTP context
to capture the acting admin and client IP. A query service exposes the trail
with pagination and filtering, so “who changed this, and when” is always
answerable.
Dashboard, analytics & auth
The admin surface is protected by JWT bearer authentication, with options validated on startup so a misconfiguration fails fast rather than at first request. A dashboard service aggregates operational metrics (bookings, contacts, email health) and an analytics service exposes trends, giving the team a single at-a-glance view.
Challenges & Solutions
Time zones. Availability rules are written in Bangladesh wall-clock time,
but slots are stored as UTC instants. Early on, “09:00” was being stored as
09:00 UTC and shown as 3 PM locally. The fix was a dedicated BusinessClock
abstraction: rule times are interpreted in the business time zone and converted
to UTC at generation time, and Google Calendar events are handed the wall-clock
time plus an IANA zone id so Google stores the correct instant.
Slot lifecycle after rule edits. Editing or deleting an availability rule shouldn’t strand mismatched slots or silently drop confirmed bookings. The generator distinguishes cases carefully: future unbooked slots from a changed rule are removed, future booked slots are deactivated (bookings preserved, no new bookings accepted), and past slots are always kept as history.
Third-party failure isolation. Both Google Calendar and email are treated as untrusted collaborators. Calendar calls are best-effort and run after commit; email goes through the durable outbox. Neither can take down the core booking flow.
Consistent errors. Domain exceptions mapped to Problem Details responses give the frontend a predictable error contract, so the UI can distinguish “slot full” (conflict) from “not found” from “validation failed” without string-matching messages.
Deployment
The service ships as a Docker image and runs on Render, configured through
render.yaml. Configuration and secrets come from environment variables (with a
gitignored .env for local development), and connection strings accept either
native Npgsql key-value form or a postgres:// URI, so a provider-issued string
can be pasted in as-is. Health checks (including a database readiness probe) back
the platform’s health monitoring, and database migrations can be applied
automatically on startup behind a config flag. Serilog provides structured
request and application logging, with X-Forwarded-* headers honored so the real
client IP and scheme survive the reverse proxy.
Takeaways
- Treat third parties as fallible by default. Making Google Calendar best-effort and email durable-by-queue turned two potential single points of failure into non-events. The booking succeeds regardless.
- Push side effects out of read paths. Making slot generation an explicit admin action — not a side effect of a public read — removed a whole class of “why did these slots come back?” bugs and kept the public API predictable.
- Model time zones explicitly. A single
BusinessClockboundary between wall-clock rules and UTC storage prevented a category of off-by-hours errors. - Optimistic concurrency is enough. For last-seat contention, a version token plus a transaction and a friendly retry message beat heavier locking schemes in both simplicity and user experience.
- Auditability is a feature. Logging every privileged action from day one made the admin console trustworthy and debugging painless.