Why structure matters early
A clean folder layout keeps transport code understandable as endpoint count grows. Without clear boundaries, controller files accumulate business logic, DTO definitions end up scattered across unrelated directories, and middleware placement becomes unpredictable. Establishing structure early prevents those problems from compounding as the project scales, because the folder hierarchy itself communicates where each concern belongs.
The goal is straightforward: one ownership root in AppModule, predictable file placement for every transport concern, and clear separation between request mapping (controllers) and business logic (services). When a new team member opens the project, the folder tree should answer "where does this go?" without requiring a conversation.
This matters more in HTTP modules than in typical Discord command code because HTTP endpoints are a public contract. Clients depend on path stability, response shapes, and error formats. Disorganized transport code makes it harder to audit what's exposed and easier to introduce breaking changes accidentally.
Recommended structure
Role of each folder
Understanding what each directory owns prevents responsibility leaks across boundaries. Controllers handle request/response mapping, services own business rules, DTOs define validation contracts, middleware provides cross-cutting behavior, and errors model domain-specific failure scenarios.
Focused file responsibilities
import { HttpController, HttpGet, HttpParam, HttpPatch, HttpBody, HttpStatus } from '@spraxium/http';
import { NotFoundError } from '@spraxium/http';
import { GuildHttpService } from '../services/guild-http.service';
import { UpdateGuildConfigDto } from '../dto/update-guild-config.dto';
@HttpController('/guilds')
export class GuildHttpController {
constructor(private readonly service: GuildHttpService) {}
@HttpGet('/:guildId')
async getGuild(@HttpParam('guildId') guildId: string) {
const guild = await this.service.getGuildSummary(guildId);
if (!guild) throw new NotFoundError('Guild not found');
return { ok: true, ...guild };
}
@HttpPatch('/:guildId/config')
@HttpStatus(200)
async updateConfig(
@HttpParam('guildId') guildId: string,
@HttpBody(UpdateGuildConfigDto) body: UpdateGuildConfigDto,
) {
return this.service.updateConfig(guildId, body);
}
}Why controllers should stay thin
Controllers are the boundary between HTTP transport and application logic. Their job is to read parameters from the request, delegate work to services, and shape the response. When controllers accumulate business rules, they become hard to test because you need a full HTTP context to exercise domain behavior. They also become hard to reuse because the same logic cannot be called from a different entry point like a Signal handler or a scheduled task.
The pattern is consistent: the controller calls the service, the service calls BotBridge or other dependencies, and errors propagate upward through the HttpError hierarchy until the centralized onError handler normalizes them. This layering keeps each file focused and each test isolated.
Services are resolved by RouteRegistry using topological sort, which means they can depend on each other as long as there are no circular references. If a circular dependency is detected, startup fails immediately with a clear error message listing the involved classes.
Scaling to bounded contexts
When the API surface grows beyond a few controllers, flat folders become noisy. At that point, split src/http into domain-specific subdirectories. Each subdirectory gets its own controllers, services, DTOs, and errors while still registering everything through the same @HttpClientModule metadata on AppModule.
Practical evolution steps
Start from one transport folder
Begin with src/http containing controllers, services, and DTO folders even for a small API surface. This gives your team a stable mental map before the project scales. Avoid putting HTTP files directly in src/ because they will mix with Discord command code.
Promote shared logic to services
Keep controllers thin by moving non-trivial business rules into service classes. Route handlers should coordinate request and response concerns, not own domain behavior. Services also get injected with BotBridge, which keeps Discord interaction behind a clean abstraction.
Create custom error classes early
Define domain-specific HttpError subclasses in an errors/ folder instead of throwing generic errors with string messages. Named errors are easier to find in logs, test in assertions, and filter in monitoring dashboards.
Split by bounded context when needed
As endpoint volume grows, split src/http into domain folders such as guilds/, moderation/, and members/. Keep app.module.ts as the single composition root for all contexts. Each domain folder owns its controllers, services, and DTOs.
Protect ownership boundaries
Continue using one defineHttp entry and one AppModule metadata source even after refactors. Multiple metadata roots make lifecycle and security policy hard to reason about. All controllers from all domains are listed in the same HttpClientModule controllers array.