Skip to content

Web Layer

The Web Layer is the outermost layer of 2KRIKA's clean architecture, handling HTTP requests, WebSocket connections, and dependency injection. Built with NestJS, it serves as the entry point for all external interactions.

Overview

Located in the web/ directory, this layer:

  • Handles HTTP requests and responses via controllers
  • Manages WebSocket connections for real-time features
  • Performs dependency injection using NestJS modules
  • Applies guards and filters for authentication and error handling
  • Transforms DTOs to API responses with Swagger documentation
  • Orchestrates use cases without implementing business logic

Controllers

Controllers handle HTTP endpoints and delegate to use cases.

Controller Anatomy

// web/users/users.controller.ts
@Controller("users")
@UseGuards(AuthGuard)
export class UsersController {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly notifier: Notifier,
    private readonly storage: StorageService,
  ) {}

  @Post()
  @NoAuth() // Public endpoint
  @ApiCreatedResponse({ type: UserResponse })
  @ApiBadRequestResponse()
  async create(@Body() body: UserCreateSchema) {
    return await userUseCases.create(
      body,
      this.userRepository,
      this.notifier,
      logger,
    );
  }

  @Get("me")
  @ApiOkResponse({ type: MeResponse })
  async getMe(@Req() request: AuthenticatedRequest) {
    return await userUseCases.getMe(
      request.user.id,
      this.userRepository,
      this.orderRepository,
      this.serviceRepository,
      this.competencyRepository,
      this.commentRepository,
      this.accountRepository,
    );
  }

  @Patch("me")
  @ApiOkResponse({ type: UserResponse })
  @ApiBadRequestResponse()
  async updateMe(
    @Req() request: AuthenticatedRequest,
    @Body() body: UserUpdateSchema,
  ) {
    return await userUseCases.updateMe(
      body,
      this.userRepository,
      request.user,
    );
  }

  @Delete("me")
  @HttpCode(HttpStatus.NO_CONTENT)
  @ApiNoContentResponse()
  async deleteMe(@Req() request: AuthenticatedRequest) {
    await userUseCases.deleteMe(request.user.id, this.userRepository);
  }
}

Key characteristics: - Decorators – NestJS routing and metadata - Dependency injection – Repositories injected via constructor - Minimal logic – Delegates to use cases - Swagger documentation – API response types - Guards – Authentication and authorization

File Upload Example

// web/users/users.controller.ts
@Post("me/profile-picture")
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiBody({
  schema: {
    type: "object",
    properties: {
      file: {
        type: "string",
        format: "binary",
      },
    },
  },
})
async uploadProfilePicture(
  @Req() request: AuthenticatedRequest,
  @UploadedFile() file: Express.Multer.File,
) {
  const uploadFile = multerToFile(file);
  return await userUseCases.uploadProfilePicture(
    request.user.id,
    uploadFile,
    this.userRepository,
    this.storage,
  );
}

Key features: - File interceptor – Handles multipart/form-data - Multer integration – File upload middleware - File conversion – Transform to domain File type - Swagger support – Documents file upload

Query Parameters

// web/orders/orders.controller.ts
@Get()
@ApiOkResponse({ type: OrdersListResponse })
async list(
  @Req() request: AuthenticatedRequest,
  @Query() query: ListOrdersQuery,
) {
  return await orderUseCases.list(
    query,
    request.user,
    this.orderRepository,
    this.userRepository,
  );
}

Query DTO:

// web/orders/orders.controller.types.ts
export class ListOrdersQuery extends PaginationQuery {
  @ApiPropertyOptional()
  state?: OrderState;

  @ApiPropertyOptional()
  search?: string;

  @ApiPropertyOptional({ type: [String] })
  orderBy?: string[];
}

export class PaginationQuery {
  @ApiPropertyOptional({ default: 1 })
  page?: number = 1;

  @ApiPropertyOptional({ default: 20 })
  pageSize?: number = 20;
}

Guards

Guards control access to routes based on authentication and authorization.

Authentication Guard

// web/auth/auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private authService: AuthService,
    private logger: FileLogger,
    private userRoleRepository: UserRoleSqlRepository,
    private userRepository: UserRepository,
    private readonly reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Check if route is public
    const isPublic = this.reflector.get<boolean>(
      "public",
      context.getHandler(),
    );

    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    // Allow public routes without token
    if (isPublic && !token) {
      return true;
    }

    if (!token) {
      throw new UnauthorizedException();
    }

    try {
      // Verify JWT token
      const payload = await this.authService.verifyAccessToken(token);

      // Load user from database
      const user = await this.userRepository.get(payload.id);

      // Fetch user permissions
      await this.userRoleRepository.getAllPermissions(user);

      // Attach user to request
      request["user"] = user;
      return true;
    } catch (e: any) {
      this.logger.error(`Auth guard error: ${e.message}`);
      throw new UnauthorizedException();
    }
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(" ") ?? [];
    return type === "Bearer" ? token : undefined;
  }
}

Key features: - JWT verification – Validates access tokens - User loading – Fetches user from database - Permission loading – Attaches permissions to user object - Public routes – Supports @NoAuth() decorator - Request augmentation – Adds user to request object

Public Route Decorator

// web/auth/auth.guard.ts
export const NoAuth = () => SetMetadata("public", true);

// Usage in controller
@Post("register")
@NoAuth()
async register(@Body() body: any) {
  // Public endpoint
}

Exception Filters

Filters catch exceptions and transform them into HTTP responses.

Validation Error Filter

// web/http-exception.filter.ts
@Catch(ValidationError)
export class BadRequestExceptionFilter implements ExceptionFilter {
  catch(exception: ValidationError, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const status = 400;

    response.status(status).json(exception.json());
  }
}

Response format:

{
  "email": ["Email already in use"],
  "password": ["Password must be at least 8 characters"]
}

Not Found Filter

// web/http-exception.filter.ts
@Catch(NotFound)
export class NotFoundExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = 404;

    response.status(status).json({
      statusCode: status,
      message: exception.message,
      path: request.url,
    });
  }
}

Forbidden Error Filter

// web/http-exception.filter.ts
@Catch(ForbiddenError)
export class ForbiddenExceptionFilter implements ExceptionFilter {
  catch(exception: ForbiddenError, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = 403;

    response.status(status).json({
      statusCode: status,
      message: exception.message,
      path: request.url,
    });
  }
}

Registering Filters

// web/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Register global exception filters
  app.useGlobalFilters(
    new BadRequestExceptionFilter(),
    new NotFoundExceptionFilter(),
    new ForbiddenExceptionFilter(),
  );

  await app.listen(3000);
}

Dependency Injection

NestJS modules configure dependency injection.

Module Structure

// web/users/users.module.ts
@Module({
  imports: [AuthModule], // Import other modules
  providers: [
    // Notifier service
    EmailNotifier,

    // Repository implementations
    IdentitySqlRepository,
    { provide: ServiceRepository, useClass: ServiceSqlRepository },
    { provide: IdentityRepository, useClass: IdentitySqlRepository },
    { provide: OrderRepository, useClass: OrderSqlRepository },
    { provide: AccountRepository, useClass: AccountSqlRepository },
    { provide: CompetencyRepository, useClass: CompetencySqlRepository },
    { provide: CommentRepository, useClass: CommentSqlRepository },
    { provide: UserRoleRepository, useClass: UserRoleSqlRepository },
  ],
  controllers: [UsersController, KYCController],
})
export class UsersModule {}

Key features: - Interface binding – Domain interfaces → concrete implementations - Service registration – Notifiers, storage, logging - Module imports – Reuse dependencies from other modules - Controller registration – HTTP endpoints

Root Module

// web/app.module.ts
@Module({
  imports: [
    AuthModule,
    UsersModule,
    ServicesModule,
    OrdersModule,
    StatsModule,
    ConfigModule,
    AccountsModule,
    ScheduleModule.forRoot(),
  ],
})
export class AppModule {}

Provider Patterns

Class provider:

providers: [UserSqlRepository]
// Shorthand for:
// { provide: UserSqlRepository, useClass: UserSqlRepository }

Interface provider:

providers: [
  { provide: UserRepository, useClass: UserSqlRepository }
]
// Binds abstract class to concrete implementation

Factory provider:

providers: [
  {
    provide: StorageService,
    useFactory: (fileSystem: FileSystem, uploadRepo: UploadRepository) => {
      return new StorageService(fileSystem, uploadRepo);
    },
    inject: [LocalFileSystem, UploadRepository],
  },
]

WebSocket Gateway

Real-time communication using Socket.IO.

Chat Gateway

// web/orders/chat.gateway.ts
@WebSocketGateway({
  cors: {
    origin: "*",
  },
})
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  constructor(
    private readonly chatRepository: ChatRepository,
    private readonly userRepository: UserRepository,
    private readonly authService: AuthService,
  ) {}

  async handleConnection(client: Socket) {
    try {
      // Authenticate WebSocket connection
      const token = client.handshake.auth.token;
      const payload = await this.authService.verifyAccessToken(token);
      const user = await this.userRepository.get(payload.id);

      // Store user in socket metadata
      client.data.user = user;

      // Join user's personal room
      client.join(`user:${user.id}`);

      console.log(`User ${user.id} connected`);
    } catch (error) {
      client.disconnect();
    }
  }

  handleDisconnect(client: Socket) {
    const user = client.data.user;
    if (user) {
      console.log(`User ${user.id} disconnected`);
    }
  }

  @SubscribeMessage("chat:join")
  async handleJoinChat(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { chatId: string },
  ) {
    const user = client.data.user;

    // Verify user has access to chat
    const chat = await this.chatRepository.get(data.chatId);
    const isMember = await chatUseCases.isMember(
      user.id,
      chat.id,
      this.chatRepository,
    );

    if (!isMember) {
      throw new WsException("Forbidden");
    }

    // Join chat room
    client.join(`chat:${data.chatId}`);

    return { success: true };
  }

  @SubscribeMessage("chat:send_message")
  async handleSendMessage(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { chatId: string; content: string },
  ) {
    const user = client.data.user;

    // Create message via use case
    const message = await chatUseCases.sendMessage(
      data.chatId,
      data.content,
      user,
      this.chatRepository,
    );

    // Broadcast to chat room
    this.server.to(`chat:${data.chatId}`).emit("chat:new_message", {
      id: message.id,
      chatId: message.chatId,
      senderId: message.senderId,
      content: message.content,
      createdAt: message.createdAt,
    });

    return { success: true };
  }

  @SubscribeMessage("chat:typing")
  async handleTyping(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { chatId: string },
  ) {
    const user = client.data.user;

    // Broadcast typing indicator (excluding sender)
    client.to(`chat:${data.chatId}`).emit("chat:user_typing", {
      chatId: data.chatId,
      userId: user.id,
      userName: user.fullName,
    });
  }
}

Key features: - Authentication – Verify JWT on connection - Room management – Join/leave chat rooms - Event handlers@SubscribeMessage decorators - Broadcasting – Emit to specific rooms - User context – Store authenticated user in socket

API Response Types

Swagger Documentation

// web/users/users.controller.types.ts
export class UserResponse {
  @ApiProperty()
  id: string;

  @ApiProperty()
  email: string;

  @ApiProperty()
  fullName: string;

  @ApiProperty()
  username: string;

  @ApiProperty()
  isActive: boolean;

  @ApiProperty()
  isKYCVerified: boolean;

  @ApiProperty()
  createdAt: Date;
}

export class UsersListResponse {
  @ApiProperty({ type: [UserResponse] })
  results: UserResponse[];

  @ApiProperty()
  count: number;

  @ApiProperty()
  page: number;

  @ApiProperty()
  pageSize: number;
}

export class UserCreateSchema {
  @ApiProperty()
  fullName: string;

  @ApiProperty()
  email: string;

  @ApiProperty()
  password: string;

  @ApiPropertyOptional()
  username?: string;
}

Benefits: - Auto-generated OpenAPI spec – Swagger UI documentation - Type safety – TypeScript interfaces - Validation – Can be combined with class-validator - Client generation – Generate API clients from spec

Middleware

CORS Configuration

// web/main.ts
app.enableCors({
  origin: process.env.CORS_ORIGIN || "*",
  credentials: true,
});

Request Logging

// web/main.ts
app.use((req, res, next) => {
  logger.info(`${req.method} ${req.url}`);
  next();
});

Application Bootstrap

// web/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { Config } from "@/app/config";
import {
  BadRequestExceptionFilter,
  ForbiddenExceptionFilter,
  NotFoundExceptionFilter,
} from "./http-exception.filter";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // CORS
  app.enableCors({
    origin: process.env.CORS_ORIGIN || "*",
    credentials: true,
  });

  // Global prefix
  app.setGlobalPrefix("api/v1");

  // Exception filters
  app.useGlobalFilters(
    new BadRequestExceptionFilter(),
    new NotFoundExceptionFilter(),
    new ForbiddenExceptionFilter(),
  );

  // Swagger documentation
  const config = new DocumentBuilder()
    .setTitle("2KRIKA API")
    .setDescription("The 2KRIKA API documentation")
    .setVersion("1.0")
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup("api/docs", app, document);

  // Load configuration
  await Config.loads();

  // Start server
  const port = process.env.PORT || 3000;
  await app.listen(port);
  console.log(`Application is running on: http://localhost:${port}`);
}

bootstrap();

Web Module Organization

web/
├── accounts/           # Account endpoints
│   ├── accounts.module.ts
│   ├── accounts.controller.ts
│   └── accounts.controller.types.ts
├── auth/               # Authentication
│   ├── auth.module.ts
│   ├── auth.controller.ts
│   ├── auth.service.ts
│   ├── auth.guard.ts
│   └── auth.controller.types.ts
├── orders/             # Order endpoints
│   ├── orders.module.ts
│   ├── orders.controller.ts
│   ├── orders.controller.types.ts
│   ├── payments.controller.ts
│   ├── chat.controller.ts
│   ├── chat.gateway.ts (WebSocket)
│   └── comments.controller.ts
├── services/           # Service endpoints
│   ├── services.module.ts
│   ├── services.controller.ts
│   ├── services.controller.types.ts
│   └── categories.controller.ts
├── users/              # User endpoints
│   ├── users.module.ts
│   ├── users.controller.ts
│   ├── users.controller.types.ts
│   ├── kyc.controller.ts
│   └── kyc.controller.types.ts
├── stats/              # Statistics endpoints
│   ├── stats.module.ts
│   └── stats.controller.ts
├── config/             # Configuration endpoints
│   ├── config.module.ts
│   └── config.controller.ts
├── shared/             # Shared web utilities
│   ├── renderers/
│   │   └── csv.ts
│   └── swagger/
│       └── types.ts
├── app.module.ts       # Root module
├── main.ts             # Application entry point
├── http-exception.filter.ts
├── types.ts            # Shared types
└── utils.ts            # Utility functions

Best Practices

✅ Do

  • Keep controllers thin – Delegate to use cases
  • Use dependency injection – Inject repositories and services
  • Document with Swagger – Add API response types
  • Apply guards – Protect routes with authentication
  • Handle errors – Use exception filters
  • Validate input – Use DTOs and validators

❌ Don't

  • Don't put business logic in controllers – It belongs in use cases
  • Don't bypass guards – Always authenticate when needed
  • Don't expose internal errors – Map to user-friendly messages
  • Don't skip Swagger docs – API documentation is essential
  • Don't hardcode URLs – Use environment variables
  • Don't forget CORS – Configure for your frontend

Testing Controllers

// web/users/users.controller.spec.ts
describe("UsersController", () => {
  let controller: UsersController;
  let mockUserRepository: jest.Mocked<UserRepository>;
  let mockNotifier: jest.Mocked<Notifier>;

  beforeEach(async () => {
    mockUserRepository = createMockUserRepository();
    mockNotifier = createMockNotifier();

    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        { provide: UserRepository, useValue: mockUserRepository },
        { provide: Notifier, useValue: mockNotifier },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
  });

  it("should create a user", async () => {
    const body = {
      fullName: "John Doe",
      email: "john@example.com",
      password: "password123",
    };

    const result = await controller.create(body);

    expect(result).toHaveProperty("id");
    expect(result.email).toBe("john@example.com");
    expect(mockUserRepository.add).toHaveBeenCalled();
  });
});

Summary

The Web Layer:

  • Handles HTTP and WebSocket communication
  • Manages dependency injection using NestJS modules
  • Applies authentication and authorization via guards
  • Transforms exceptions to HTTP responses using filters
  • Documents APIs with Swagger/OpenAPI
  • Delegates business logic to use cases

This layer is the entry point to the application but contains minimal logic, keeping the architecture clean and testable.