DEV Community

Mark Flame
Mark Flame

Posted on

Atomic Student Creation Across Microservices: Why I Reached for a Compensating Transaction

Atomic Student Creation Across Microservices: Why I Reached for a Compensating Transaction

SkillUp Africa, the EdTech platform I'm building, is a NestJS monorepo split
into an auth-service, a school-service, and an API gateway that talks to
both over TCP. Splitting things this way is great for separation of concerns
— until you hit an operation that genuinely needs to touch both services in
one logical unit of work. Creating a student is exactly that operation, and
it's what forced me to actually think about distributed consistency instead
of assuming it for free.

Why this is harder than it looks

In a monolith, creating a student would be one database transaction:
insert the user account, insert the student record, commit. If anything
fails, the whole thing rolls back automatically and you're left with either
nothing or a fully-created student. Postgres/Mongo transactions give you that
guarantee for free within a single database.

The moment you split into services with separate databases, that guarantee
disappears. In SkillUp, creating a student (specifically, a school admin
creating a managed student account) means:

  1. auth-service creates the user record — hashed credentials, role, a server-generated username
  2. school-service creates the student entity — linked to a school, a class, guardian info, etc.

These are two separate databases behind two separate services talking over
TCP via @MessagePattern handlers. There is no single database transaction
that can wrap both. If step 1 succeeds and step 2 fails — school-service is
down, a validation fails, the network hiccups — you're left with a user
account that can log in but has no student record attached to it. That's not
a cosmetic bug. It's an orphaned identity in the system, and depending on
what the gateway does next, it can look to the admin like the creation
"failed" when actually it half-succeeded.

The naive fixes, and why I didn't use them

Two-phase commit (2PC). The classic distributed-transaction answer. I
considered it and ruled it out fast — it requires a transaction coordinator,
both services need to support prepare/commit phases, and it doesn't play
well with service unavailability (a participant going down mid-transaction
can leave things locked). For a two-service, low-throughput operation like
"admin creates a student," that's a lot of infrastructure for the problem
at hand.

Just hope it doesn't fail. Tempting when you're moving fast, genuinely
the wrong call for anything touching account creation. Auth bugs are the
kind that erode trust in a platform fastest.

What I actually built: a compensating transaction (saga pattern, simplified)

The pattern I used is a straightforward saga: perform step one, and if step
two fails, explicitly undo step one. No coordinator, no distributed locks —
just the gateway (or an orchestrating service) being responsible for
cleanup when a later step fails.

// gateway / orchestrating service (simplified)
async createManagedStudent(dto: CreateStudentDto, adminSchoolId: string) {
  // schoolId is resolved from the admin's JWT, never trusted from the request body
  const schoolId = adminSchoolId;

  const user = await this.authClient
    .send('auth.create-managed-user', {
      role: 'student',
      // username is always server-generated — never accepted from the client
      username: generateUsername(dto.firstName, dto.lastName),
    })
    .toPromise();

  try {
    const student = await this.schoolClient
      .send('school.create-student', { ...dto, schoolId, userId: user.id })
      .toPromise();

    return student;
  } catch (err) {
    // school-service failed — compensate by rolling back the user we just created
    await this.authClient
      .send('auth.delete-user', { userId: user.id })
      .toPromise();

    throw new InternalServerErrorException(
      'Student creation failed and was rolled back.',
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

A few details here matter as much as the try/catch structure itself:

  • schoolId is always resolved from the admin's JWT, never from the request body. If it were client-supplied, any authenticated admin could potentially create a student under a different school just by changing a field in the payload. This isn't strictly part of the compensating transaction pattern, but it lives in the same code path and it's the kind of thing that's easy to get wrong when you're focused on the transaction logic and lose sight of the trust boundary.
  • The username is server-generated, not client-supplied. This removes an entire class of bugs and abuse (duplicate usernames, impersonation attempts, injection through a username field) at the source, rather than validating it after the fact.
  • The compensation step (auth.delete-user) has to be idempotent and reliable in its own right. If the delete call itself fails, you now have the orphaned-user problem you were trying to avoid, just one layer deeper. In practice this means the compensation call needs its own retry handling, and worst case, a background reconciliation job that periodically checks for users with no linked student record and flags or cleans them up. I haven't needed the reconciliation job yet at current scale, but I'm not pretending the try/catch alone is a complete guarantee — it's the first line of defense, not the only one.

Why not just make school-service the source of truth and skip auth-service entirely?

I considered this too — collapse the operation into one service so there's
one database and normal ACID transactions apply. It doesn't work here
because auth-service genuinely owns authentication concerns (password
hashing, token issuance, role management) across all user types in the
platform, not just students, and I didn't want to duplicate that logic or
fracture where credentials live. The service boundary is doing real work;
the compensating transaction is the cost of keeping that boundary honest
rather than collapsing it for the sake of one operation's convenience.

The actual lesson

Splitting a system into services doesn't remove the need for transactional
thinking — it just moves the responsibility from the database to your
application code. Postgres and MongoDB give you strong consistency
guarantees for free within their own boundary; the moment an operation spans
two databases, you become the transaction coordinator, whether you
planned to or not. The saga pattern isn't a magic fix, it's an honest
acknowledgment of that: define what "undo" means for every step that can
fail after an earlier step already succeeded, and make sure the undo path
is at least as reliable as the thing it's undoing.

Top comments (0)