HumanOnlyWeb

🍁 writer of code. drinker of coffee. human on the web.

A Layered Architecture for Nuxt Fullstack applications [Part 1 — Server side]

By HumanOnlyWeb

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!

All Comments 0

Be the first to comment.