A Layered Architecture for Nuxt Fullstack applications [Part 1 — Server side]
As your fullstack Nuxt app grows, you'll need more structure than just routes calling the database directly. Here's a layered architecture that scales with complexity.
Background
If you've read Michael Thiessen's article on The Repository Pattern in Nuxt, you know the benefits of separating your data access layer from your routes. It's a clean pattern that works great for many applications.
But what happens when your routes need more than just CRUD operations? What if you need to:
- Coordinate multiple data sources in a single request
- Run authorization checks before touching the database
- Validate complex input with business rules
- Handle cross-cutting concerns like logging or rate limiting
This is where a layered architecture comes in. Instead of routes calling repositories directly, we add a controller layer that orchestrates the work.
The Layers (Not Nuxt Layers!)
Here's the structure I use:
Each layer has a single responsibility:
Routes: Keep Them Thin
Routes should be minimal. Their only job is to get the controller and call it:
// server/api/posts/[slug]/comments.post.ts
export default defineEventHandler(async (event) => {
const { commentController } = useContainer(event);
try {
return await commentController.create(event);
} catch (error) {
return handleError(event, error);
}
});
Simple and clean. No validation, no business logic, no database queries. Just get the controller and delegate.
Controllers: The Orchestrators
Controllers handle everything between HTTP and the data layer:
// server/features/comment/comment.controller.ts
export class CommentController {
constructor(
private commentService: CommentService,
private userService: UserService,
private postSettingsService: PostSettingsService
) {}
async create(event: H3Event) {
// 1. Validate input
const [{ slug }, body] = await Promise.all([
validateRouterParams(event, slugParamsSchema),
validateRequestBody(event, createCommentSchema),
]);
// 2. Check authentication (using nuxt-auth-utils for this example)
const session = await requireUserSession(event);
// 3. Business rules
const settings = await this.postSettingsService.getBySlug(slug);
if (!settings.commentsEnabled) {
throw Errors.forbidden("Comments are disabled for this post");
}
// 4. Create the comment
return this.commentService.create({
postSlug: slug,
userId: user.id,
content: body.content,
parentId: body.parentId
});
}
}
Notice how the controller coordinates three different services to fulfill one request. This kind of orchestration, in my experience, is something that would make a repository feel bloated.
Services: Pure Business Logic
Services know nothing about HTTP. They receive a database connection and do their job:
// server/features/comment/comment.service.ts
export class CommentService {
constructor(private db: Database) {}
async create(params: {
postSlug: string;
userId: string;
content: string;
parentId?: string;
}) {
const now = Date.now();
const comment = await this.db.insert(comments).values({
id: randomId("cmt"),
postSlug: params.postSlug,
userId: params.userId,
content: params.content,
parentId: params.parentId ?? null,
createdAt: now,
updatedAt: now
}).returning();
}
async findById(id: string) {
return this.db.query.comments.findFirst({
where: eq(comments.id, id),
with: { user: true }
});
}
}
Services are easy to test because they don't depend on HTTP context. Pass in a mock database and you're good to go.
Dependency Injection
How do controllers get their services? Through a simple container. A simple implementation could look like this:
// server/utils/container.ts
export function useContainer(event: H3Event): Container {
const db = event.context.db;
// Create services
const commentService = new CommentService(db);
const userService = new UserService(db);
const postSettingsService = new PostSettingsService(db);
// Create controllers with their dependencies
const commentController = new CommentController(
commentService,
userService,
postSettingsService
);
return {
commentService,
userService,
postSettingsService,
commentController,
};
}
This creates fresh instances per request. (This approach makes sense to me, especially since I mainly host on Cloudflare Workers where everything is short-lived.)
Why Not Just Use Repositories?
In my opinion, the repository pattern works great when:
- Your routes mostly do CRUD operations
- Business logic is minimal
- You have a *smaller codebase and don't need to coordinate multiple data sources
Our layered approach adds value when:
- Routes need to coordinate multiple services
- You have complex authorization rules
- Input validation goes beyond simple schemas
- You want clear boundaries for testing
It's not about one being "better" than the other. Different applications have different needs. A simple blog might be perfectly served by the repository pattern. A SaaS with user permissions, feature flags, and complex workflows might benefit from controllers.
Testing
The layered architecture makes testing straightforward:
// server/tests/services/comment.service.test.ts
describe("CommentService", () => {
let service: CommentService;
let mockDb: MockDatabase;
beforeEach(() => {
mockDb = createMockDatabase();
service = new CommentService(mockDb);
});
it("creates comment with generated ID", async () => {
const result = await service.create({
postSlug: "test-post",
userId: "usr_123",
content: "Test comment"
});
expect(result.id).toMatch(/^cmt_/);
});
});
No HTTP mocking needed. Services are just classes that take a database.
Conclusion
As with all things programming: there's no ultimate architecture.
The repository pattern is elegant and works for many apps. A layered architecture with controllers might add *overhead but provides structure for complex business logic.
Start simple. If your routes start accumulating authorization checks, coordinating multiple queries, or validating complex business rules, consider introducing controllers. If not, repositories might be all you need.
I'm not married to this approach and will adapt as I learn more or discover benefits of other patterns. But so far, it's served me well in building and maintaining Nuxt fullstack applications.
Let me know in the comments what patterns you use to organize your Nuxt fullstack apps!