Best Practices for Structuring Express.js Applications with Prisma Design Pattern
I've seen Express projects where everything lives in one giant app.js. Routes, database calls, validation, error handling — all tangled together. It works...
24 Mar 2024

I've seen Express projects where everything lives in one giant app.js. Routes, database calls, validation, error handling — all tangled together. It works until the second developer joins. Then it becomes a merge-conflict minefield.
Here's how I structure Express + Prisma projects to stay sane as they grow.
Folder Structure
Everything lives under /src:
/controllers/— Handle HTTP requests. Parse input, call services, return responses. No business logic here./services/— Business logic lives here. Authentication, validation, orchestration. Controllers call services, never the database directly./models/— Prisma schema and generated client. This is your data layer contract./routes/— Route definitions. Map endpoints to controller methods. Keep them thin./middlewares/— Auth checks, logging, rate limiting, error handling. Anything that intercepts requests./config/— Environment-specific settings. Database URLs, API keys, feature flags./utils/— Shared helpers. Date formatting, slug generation, etc./tests/— Unit, integration, and end-to-end tests.app.js— Wires up middleware, routes, and config.index.js— Starts the server.
The Rules I Follow
Controllers don't touch the database. They call services. Services call Prisma. This separation means I can test business logic without spinning up an HTTP server.
Routes are just wiring. A route file maps a URL to a controller method. No logic. If you're writing if statements in a route file, something is wrong.
Middleware handles cross-cutting concerns. Logging, auth, error formatting — these belong in middleware, not scattered across controllers.
One service per domain concept. UserService, OrderService, AuthService. Each owns its slice of logic. When services need to talk, they call each other — not reach into each other's database queries.
Centralize error handling. One error-handling middleware catches everything. Controllers throw typed errors (NotFoundError, ValidationError). The middleware maps them to HTTP status codes.
Use environment variables for config. Never hardcode database URLs or API keys. Use .env files locally, environment variables in production.
Inject dependencies where it matters. Pass the Prisma client into services rather than importing it directly. This makes testing with mocks trivial.
Security Basics
- Validate and sanitize all input. Prisma parameterizes queries by default, but don't rely on that alone.
- Use Helmet for HTTP headers.
- Rate-limit sensitive endpoints.
- Never return raw error stacks to clients in production.
Testing Strategy
- Unit tests for services. Mock Prisma calls. Test business rules in isolation.
- Integration tests for routes. Spin up the app, hit endpoints, verify responses.
- End-to-end tests for critical flows. Use a test database with seed data.
The benefit: Clear ownership of code. New developers find things fast. Tests are focused. Refactoring one layer doesn't break the others.
The cost: More files upfront. For a simple CRUD API with three endpoints, this structure is overkill. Start simple and refactor into this shape when the codebase earns it.