The feature-first file structure
Group files by feature, not by type. Instead of controllers/, services/, models/, group everything about a feature together.
src/
features/
auth/
auth.router.ts
auth.service.ts
auth.middleware.ts
auth.types.ts
auth.test.ts
projects/
projects.router.ts
projects.service.ts
projects.repo.ts
projects.types.ts
projects.test.ts
lib/
db.ts
redis.ts
logger.ts
app.ts
index.tsEvery file related to auth is in features/auth/. No hunting across three directories to understand a feature.
Barrel exports for clean imports
// features/auth/index.ts
export { authRouter } from './auth.router';
export { AuthService } from './auth.service';
export type { AuthUser } from './auth.types';
// In another file:
import { authRouter, AuthService } from '@/features/auth';Dependency injection without a framework
You don't need InversifyJS. Constructor injection with TypeScript interfaces is usually enough:
export class ProjectService {
constructor(
private readonly repo: ProjectRepository,
private readonly cache: CacheService,
private readonly events: EventEmitter,
) {}
async create(data: CreateProjectInput): Promise<Project> {
const project = await this.repo.create(data);
await this.cache.invalidate(`projects:user:${data.userId}`);
this.events.emit('project.created', project);
return project;
}
}Router composition
Each feature owns its router. The app assembles them with a prefix.
// app.ts
import { authRouter } from '@/features/auth';
import { projectsRouter } from '@/features/projects';
app.use('/auth', authRouter);
app.use('/projects', requireAuth, projectsRouter);Feature flags
For gradual rollouts, implement a simple flag system keyed by user or percentage rollout before reaching for a paid tool:
const flags = {
newDashboard: (user: User) => user.betaAccess,
fastSearch: (user: User) => hashUser(user.id) % 100 < 20, // 20% rollout
};
app.get('/dashboard', (req, res) => {
if (flags.newDashboard(req.user)) {
return renderNewDashboard(req, res);
}
return renderOldDashboard(req, res);
});Versioning your API
Prefix routes with /v1 from day one. When you need to make a breaking change, add /v2 alongside. Deprecate v1 with a sunset header. Never break existing clients.
app.use('/v1', v1Router);
app.use('/v2', v2Router);