Day#3: Behind the Scenes: Challenges in Building a Modular, Scalable Platform

Over the past few days, I’ve been developing a backend system from scratch that’s designed to scale with a complex, multi-tenant structure. While the exact business context remains confidential, the technical journey offers plenty of insight.

This post summarizes key challenges I encountered and the approaches I used to solve them — especially around structure, documentation, and authentication via JWT.


📐 Organizing the Architecture

The system is structured as a modular monorepo with NestJS, PostgreSQL, and TypeORM. Each bounded context (e.g., “partners”, “payments”, “roles”) is a fully independent module with its own:

Avoiding circular dependencies and maintaining strict separation of concerns required deliberate planning — and some refactoring along the way.


📚 Swagger Documentation Is a Double-Edged Sword

NestJS integrates nicely with Swagger, but generating meaningful documentation requires a lot of explicit metadata:

A special caveat: when creating UpdateDto via PartialType(CreateDto), the metadata doesn't carry over. You either have to re-annotate fields or explicitly declare them again.


🔐 JWT Authentication and Role-Based Access

One of the more sensitive areas of the system is authentication and authorization. Here’s a breakdown of how JWT was introduced:

Strategies and Guards

Two Passport strategies were implemented:

Routes are protected using:

Environment Variables

The JWT module was configured to be asynchronous and environment-driven:

JwtModule.registerAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    secret: config.get<string>('JWT_SECRET'),
    signOptions: {
      expiresIn: config.get<string>('JWT_EXPIRES_IN'),
    },
  }),
})

You’ll need the following entries in your .env:

JWT_SECRET=my_super_secret_key
JWT_EXPIRES_IN=3600s

Note: expiresIn can be a number (3600) or a string (7d, '1h', '15m', etc.)

Register and Login Flow

For registration:

async register(dto: RegisterDto) {
  const existing = await this.partnerRepo.findOneBy({ email: dto.email });
  if (existing) throw new ConflictException('Email already in use');

  const passwordHash = await bcrypt.hash(dto.password, 10);

  const partner = this.partnerRepo.create({
    email: dto.email,
    firstName: dto.firstName,
    lastName: dto.lastName,
    passwordHash,
    active: true,
  });

  const saved = await this.partnerRepo.save(partner);
  const { passwordHash: _, ...result } = saved;
  return result;
}

For login:

async login(user: any) {
  const payload = {
    sub: user.id,
    email: user.email,
    roles: user.roles,
  };
  return {
    access_token: this.jwtService.sign(payload),
  };
}

The payload is later extracted in JwtStrategy.validate():

async validate(payload: any) {
  return { id: payload.sub, email: payload.email, roles: payload.roles };
}

Guarding Routes

A protected route looks like this:

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@Get('secure-data')
getAdminData(@Request() req) {
  return { data: 'This is protected content.' };
}

This setup gives fine-grained control and can scale easily for multi-role scenarios, without overcomplicating the guard logic.


💡 Final Thoughts

A working JWT-based auth system is not rocket science, but getting all the pieces — DTOs, validation, guards, environment config, token payload, role propagation — to line up is tricky.

The key was staying modular and predictable. Each responsibility lives in its own layer. Each layer is tested independently. Each feature gets documented as it's built.

That discipline is paying off.

More soon.