- Knowledge
- technology
- OOP
- Tips
- Programming
- Tips
- Tutorial
- SEO
- Ranking
- Knowledge
- Special Day
- Seo
- Bug
- Data science
- Seo
- artificial intelligence
- Machine Learning
- Robotics
- happyNewYear2021
- newYearEve
- 2021
- Automation
- Smart Home
- Career
- Best Practices
- Git
- Logging
- Web Fundamentals
- DNS
- HTTPS
- Performance
- AI Tools
- ChatGPT
- Claude
- Gemini
- Laravel
- Eloquent
- MySQL
- HTTPS
- TLS
- Web Security
- Certificates
- Developer Life
- Debugging
- Docker
- DevOps
- Transactions
- Queues
- LLMs
- AI
- AI Coding
- Developer Tools
- React Native
- Expo
- Kate PMS
- Mobile Apps
- Laravel
- Authentication
- Sanctum
- Cookies
- API Design
- Payments
- Idempotency
- DeepSeek
- Open Source AI
- LLMs
- AI News
- Git
- Version Control
- AI Coding
- Prompting
- PHP
- Checklist
- MCP
- AI Agents
- OpenAI
- Architecture
- Microservices
- Modular Monolith
- Estimation
- Developer Life
- Project Planning
- Humour
- OAuth
- OpenID Connect
- Authentication
- Embeddings
- Vector Search
- RAG
- pgvector
- OpenAI
- GPT-4.1
- Codex CLI
- Events
- Testing
- Clean Code
- Maintainability
- Code Review
- Webhooks
- API
- Security
- Claude Code
- Workflow
- AI
- LLM
- Prompt Injection
- Mobile
- React
- Networking
- TCP
- UDP
- HTTP/3
- CLAUDE.md
- AWS
- Cloud Security
- Backups
- PHPUnit
- Software Engineering
- Leadership
- Communication
- RAG
- Embeddings
- AI Engineering
- IT Infrastructure
- Networking
- Access Control
- CI/CD
- GitHub Actions
- Gemini CLI
- Claude Code
- JavaScript
- Async/Await
- Node.js
- Promises
- Security
- Cryptography
- Passwords
- MySQL
- Database
- Vibe Coding
- Software Quality
- DNS
- Code Reading
- Onboarding
- Productivity
- Background Jobs
- Developer Humour
- Estimates
- Dev Life
- JWT
- o3-mini
- DeepSeek R1
- Rate Limiting
- Kate PMS
- E-Signing
- Audit Trail
- REST
- GraphQL
- API Design
- Laravel 12
- Upgrade Guide
- Open Source
- Self-Hosting
- Task Scheduling
- Cron
- Secrets
- CORS
- PHP
- PHP-FPM
- OPcache
- GitHub Copilot
- Software Architecture
- Engineering
- TypeScript
- JavaScript
- Type Safety
- AI Security
- React Native
- Product Design
- AI Agents
- Kiro
- Queues
- Redis
- RabbitMQ
- AWS SQS
- Nginx
- Apache
- GPT-5
- gpt-oss
- Clean Code
- Architecture
- Naming
- Documentation
- Career
- ADR
- Teamwork
- Supply Chain
- Kate HRM
- HR Software
- Permissions
- System Design
- Pagination
- SSH
- Linux
- Big O
- Databases
- Laravel Boost
- MCP
- Developer Skills
- Validation
- Databases
- Indexes
- Code Quality
- Deployment
- Developer Humour
- Feature Flags
- Code Review
- Pull Requests
- Docker
- Cursor
- Authorization
- RBAC
- Gemini
- Long Context
- PHP 8.4
- Caching
- Dependency Injection
- Web Performance
- Browser
- CSS
- Database
- Migrations
- ChatGPT
- AI for Developers
- Monitoring
- On-Call
- REST
- Backend
- SQL
- NoSQL
- Database Design
- Coding Agents
- Claude 4
- API Resources
- REST API
- Load Balancing
- Scaling
- AWS
- AI Tools
- Claude
- Sora 2
- CTE
- 2FA
- TOTP
- Programming Languages
- Prompts
- Developer Workflow
- API Gateway
- APIs
- Passport
- API Auth
- Learning
- Burnout
- Developer Growth
- Web Development
- SEO
- Kate Mall
- ChatGPT Atlas
- Agent Skills
- Middleware
- Laravel 12
- Collections
- Context Window
- Monitoring
- Commit Messages
- Self Review
- Growth
- Regex
- Programming Basics
- Text Processing
- Database Design
- Normalization
- Linux
- Server Security
- Linux Foundation
- Open Standards
- Legacy Code
- Documentation
- AI Workflow
- File Uploads
- Test Data
- Hashing
- Performance
- Caching
- Enums
- Scope Creep
- Estimation
- Codex
- Gemini CLI
- Timezones
- Carbon
- Bugs
- PHP 8.5
- Gemini 3
- GPT-5.1
- Data Integrity
- Event Loop
- Async
- Opus 4.5
- AI Models
- React
- Forms
- Frontend
- Backups
- AI Images
- DALL-E
- Midjourney
- Race Conditions
- Concurrency
- Legacy Code
- Refactoring
- Senior Engineer
- Scope
- LLM
- CDN
- Web
- Sub-Agents
- Soft Deletes
- Audit Log
- Concurrency
- AI Learning
- NestJS
- AI Evals
- Policies
- SPF DKIM DMARC
- Unicode
- UTF-8
- Knowledge Graph
- Value Objects
- Technical Debt
- Feature Flags
- Laravel Pennant
- Deployment
- Copilot
- Composer
- Dependencies
- Artisan
- Automation
- AWS S3
- Object Storage
- Cloud
- Small Language Models
- Ollama
- Production
- Sessions
- HTTP
- Mentoring
- SQL
- Virtual Machines
- Web Development
- HTTP/2
- QUIC
- Web Performance
- AI Integration
- LLM API
- SOLID
- OOP
- Hosting
- Serverless
- Merge Conflicts
- Temperature
- AI Development
- Reverse Proxy
- Nginx
- Infrastructure
- Verification
- Passkeys
- WebAuthn
- Teams
- Communication
- Stakeholders
- Monorepo
- CI/CD
- Versioning
- JSON Schema
- Livewire
- Inertia
- Meetings
- Distributed Systems
- Privacy
- Full-Stack
- T-Shaped Skills
- Money
- Notifications
- Web Security
- HTTP Headers
- CSP
- Function Calling
- Load Testing
- k6
- Data Extraction
- Debugging
- WebSockets
- SSE
- Real-Time
- Laravel Reverb
- Infrastructure as Code
- Terraform
- Side Projects
- Laravel Pint
- OpenAPI
- Swagger
- UX
- Multimodal
- Jest
- Pair Programming
- APIs
- Rate Limiting
- Resilience
- Dev Humour
- Design Tokens
- JWT
- API Keys
- Sessions
- PHPStan
- Rector
- Incidents
- Reporting
- Dashboards
- Zero Trust
- IAM
- Search
- Laravel Scout
- Junior Developers
- Mentoring
- Images
- WebP
- AVIF
- Bug Reports
- Let's Encrypt
- Design Docs
- Software Design
- Observers
- Replication
- Accountability
- Data Structures
- Reliability
- LLM Memory
- Error Handling
- Payments
- Payment Gateway
- Webhooks
- PCI DSS
- Observability
- OpenTelemetry
- Personal Brand
- Writing
- Conventions
- Dates
- Scheduling
- Disaster Recovery
- Compression
- Brotli
- Deadlines
- Developer Habits
- State Machines
- Tech Roles
- UUID
- ULID
- Horizon
- Planning
- Engineering Culture
- Ownership
- Soft Skills
- Socialite
- Cost Control
- Collations
- Unicode
- Octane
- PostgreSQL
A Clean Project Structure for TypeScript and Node.js API Services
About Post
Laravel hands you a folder structure on day one. Controllers go here, models go there, config lives in config/, and every Laravel developer can find their way around any Laravel project.
Node.js hands you an empty folder and index.js. That freedom is lovely for a weekend script and expensive for an API that will live for years. Six months in, business rules are inside route handlers, process.env is read in twenty places, and the only way to test anything is to start the whole server.
My personal API services are written in TypeScript and Node.js, tested with Jest and run in Docker, and the structure below is the shape I'd recommend for that kind of service. The examples use Express and Zod because they're widely known; the same ideas work with Fastify or another validation library.
The layout
src/
app.ts # builds the app, never calls listen()
server.ts # starts it, handles shutdown
config.ts # env vars, validated once
modules/
employees/
employee.routes.ts
employee.schema.ts
employee.service.ts
employee.repository.ts
employee.service.test.ts
shared/
errors.ts
error-handler.ts
validate.ts
Two decisions are doing the work here.
Group by feature, not by type. A controllers/, services/, models/ layout means every change touches four folders. With modules/employees/, everything about employees sits together, and deleting a feature means deleting one folder.
Separate building the app from starting it. app.ts exports a function that returns a configured app. server.ts is the only file that calls listen(). That one split is what makes fast HTTP tests possible later.
Three layers, one rule
- Routes know about HTTP: paths, status codes,
reqandres. They validate input and call a service. Nothing else. - Services hold the business rules. They take plain data and return plain data. They never see
reqorres. - Repositories talk to the database. Nothing else does.
The rule: dependencies point inward. Routes can use services, services can use repositories, never the other way round. Then a service can be called from an HTTP route, a queue consumer or a CLI script without changes.
Config: validate once, fail fast
Reading process.env.DATABASE_URL all over the code means a missing variable shows up as a strange error at 2am, deep inside a request. Validate the environment once, at startup, and export a typed object:
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().min(1),
});
export const config = EnvSchema.parse(process.env);
If a variable is missing or malformed, the service refuses to start and tells you exactly which one. That's the best possible time to find out.
Validation at the edge, types for free
A Zod schema gives you runtime validation and a TypeScript type from one definition, so they can't drift apart:
export const CreateEmployeeSchema = z.object({
name: z.string().min(1).max(255),
email: z.string().email(),
joinedAt: z.coerce.date(),
});
export type CreateEmployeeInput = z.infer<typeof CreateEmployeeSchema>;
Validate in the route layer, before anything else runs. By the time data reaches a service, it's already the right shape, and the service's parameter type says so.
Errors: typed in the core, translated at the edge
Services shouldn't decide HTTP status codes, but they do know what went wrong. Give them a small family of error classes:
export class AppError extends Error {
constructor(public readonly status: number, public readonly code: string, message: string) {
super(message);
}
}
export class NotFoundError extends AppError {
constructor(what: string) {
super(404, 'NOT_FOUND', `${what} not found`);
}
}
Then one error-handling middleware, registered last, turns them into consistent JSON. Known errors get their status and code; anything unexpected is logged in full and returned as a generic 500, so stack traces never leak to clients:
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.status).json({ error: { code: err.code, message: err.message } });
}
console.error(err); // use your real logger here
res.status(500).json({ error: { code: 'INTERNAL', message: 'Something went wrong' } });
}
Express needs all four parameters to recognise an error handler. And since Express 5, a rejected promise in an async route handler is passed to this middleware automatically, so you no longer need to wrap every handler in try/catch.
Services that are easy to test
Pass the repository into the service instead of importing it. That's all "dependency injection" needs to mean here:
export class EmployeeService {
constructor(private readonly employees: EmployeeRepository) {}
async getById(id: string) {
const employee = await this.employees.findById(id);
if (!employee) throw new NotFoundError('Employee');
return employee;
}
}
Now a Jest unit test needs no database and no server:
it('throws NotFoundError for an unknown employee', async () => {
const repo: EmployeeRepository = {
findById: jest.fn().mockResolvedValue(null),
create: jest.fn(),
};
const service = new EmployeeService(repo);
await expect(service.getById('42')).rejects.toBeInstanceOf(NotFoundError);
});
For HTTP-level tests, the app.ts split pays off: pass the app from createApp() to supertest and it handles requests in memory, with no port and no running server. Those tests check what the unit tests can't: routing, validation responses and the error format.
The test of a good structure: can you test a business rule without starting a server or a database? If yes, the layers are doing their job. If no, HTTP or SQL has leaked into the middle.
Docker: small, reproducible, not root
A multi-stage build compiles TypeScript in one image and ships only the compiled output and production dependencies in the next:
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Copying package*.json before the source keeps the dependency layer cached between builds. USER node means the process doesn't run as root. And starting with node directly, rather than through npm start, lets the process receive the stop signal from Docker, so server.ts can close connections gracefully on SIGTERM.
The checklist
- Feature folders;
app.tsseparate fromserver.ts. - Routes for HTTP, services for rules, repositories for data.
- Config validated once at startup.
- Schemas at the edge, with types inferred from them.
- Typed errors in the core, one error handler at the edge.
- Dependencies passed in, so Jest tests stay fast.
- Multi-stage Docker image, running as a non-root user.
How do you structure your Node APIs: feature folders or layer folders? And has a framework like NestJS made the question go away for you?

Be first to comment it...