DEV Community

Cover image for Week 1 Building RunHop: DDD Structure, JWT Auth, and a Race Condition I Didn't See Coming
Rhico
Rhico

Posted on

Week 1 Building RunHop: DDD Structure, JWT Auth, and a Race Condition I Didn't See Coming

I'm building RunHop — a social platform for running races — as a way to go deep on the NestJS/TypeScript stack.

The goal from day 1 was to build it the way a real production system gets built: domain-driven structure, proper auth, test coverage from the start. Not a tutorial app.

Here's what the first week looked like.

Architecture First

Before writing a single line of app code, I mapped out the domain structure. RunHop has clear bounded contexts — identity (auth, users), organization (clubs, membership), event (races, registrations) — so the folder structure reflects that:

src/ domain/ identity/ organization/ event/ shared/

Each context gets its own module, services, controllers, and DTOs. No flat src/services/ folder that becomes a dumping ground by month two.

The Prisma schema covers the full data model upfront: User, Organization, OrgMembership, Event, EventRegistration. Designing it early forced me to think through the relationships before writing business logic — which fields are required, where the foreign keys go, what cascades on delete.

This matters at scale. When every feature you add has to figure out where it lives, you lose time on decisions that should already be made. DDD structure pays for itself fast.

The Logout Problem Everyone Ignores

JWTs are stateless — which sounds great until you realize it means you can't actually log someone out. Once a token is issued, it's valid until it expires. There's no server-side session to destroy.

Most tutorials skip this entirely. The token expires in 15 minutes, so who cares? In production, you care. Users expect logout to mean logout.

The fix: store refresh tokens in Redis. On login, the refresh token gets written to Redis with a TTL matching its expiration. On logout, delete it. When the client tries to use the refresh token to get a new access token, it's gone.

async logout(userId: string): Promise<void> {
  await this.redis.del(`refresh_token:${userId}`);
}

async refreshTokens(userId: string, refreshToken: string) {
  const stored = await this.redis.get(`refresh_token:${userId}`);
  if (!stored || stored !== refreshToken) {
    throw new UnauthorizedException('Invalid refresh token');
  }
  // Issue new token pair, rotate refresh token
}
Enter fullscreen mode Exit fullscreen mode

Simple in concept. The access token still lives until it expires (short TTL), but the refresh token — the one that matters for persistence — is revocable.

The Race Condition
My E2E test was passing. It shouldn't have been.

The test flow: register → login → logout → try to use the refresh token → expect 401. Straightforward. But the logout step wasn't fully completing before the next request fired.

The problem was a missing await on the Redis del() call inside the logout handler. The function returned before Redis confirmed the deletion. Most of the time, Redis is fast enough that it didn't matter — the token was gone by the time the next request arrived. But "most of the time" isn't a test you can trust.

Once I added the await, the test became deterministic. It fails when it should fail.

Async timing bugs are the worst kind of test problem because they produce tests that pass — just not for the right reasons. If your test involves any async side effect, make sure every step is fully resolved before asserting.

Top comments (0)