Building BeautyGlam: Architecture of a Multi-Tenant SaaS

By Odilon15 min read

Building a Multi-Tenant SaaS: Architecture Lessons#

TL;DR

  • Hexagonal architecture (ports and adapters) keeps business logic independent of React and Next.js — this paid off the first time an email provider needed to be swapped without touching a single use case
  • Server Actions are the right composition root: they wire infrastructure to use cases and are the only place where both coexist
  • Multi-tenant routing under /s/[app]/ works well in App Router; the hard part is making organization context flow cleanly through the tree
  • Domain events for cross-module communication prevent spaghetti imports that would make the codebase untestable

The architecture I chose for this SaaS project was directly shaped by experience on a large enterprise e-commerce platform — both what worked and what I wanted to do differently. Working on a multi-tenant platform with multiple storefronts, I watched a massive codebase where certain architectural boundaries were enforced well and others were not. The modules that had clean boundaries were a joy to work in. The ones where someone had taken a shortcut three years ago and imported directly from another module's internals were a nightmare.

So when I started building this — a portfolio and client management tool for beauty creators — I decided to be disciplined about boundaries from day one. Even when it felt like overkill for a project with only three features.

Looking back at almost a year of building this application, most of those early decisions held up. Some didn't.

Why "Just React" Doesn't Scale#

My first instinct was to build this like I'd built every smaller Next.js app: data fetching in pages, business logic scattered across hooks and server actions, MongoDB queries wherever convenient.

That lasted about three weeks. Then the creator profile needed new fields. The media kit needed a different generation strategy. Pricing rules needed to apply across multiple features. And because the logic was everywhere, every change rippled unpredictably. I'd fix something in the profile action and break something in the media kit generator because they were both directly querying the same collection with slightly different assumptions.

If you've worked on a codebase longer than six months, you know this story.

The Module Structure#

Every feature lives in a module under src/modules/. Here's what BeautyGlam looks like:

src/modules/beauty-glam/
  domain/
    entities/
      creator-profile.ts       # business rules, ZERO framework deps
      portfolio-item.ts
      brand.ts
    value-objects/
      portfolio-item-type.ts
    events/
      contact-message-received.ts
    schemas/
      creator-profile.schema.ts  # Zod validation
  application/
    ports/
      creator-profile-repository.ts  # interface — no MongoDB here
      portfolio-item-repository.ts
    use-cases/
      create-creator-profile.ts
      update-portfolio-item.ts
      get-media-kit-data.ts
  infrastructure/
    persistence/
      mongo-creator-profile-repository.ts  # MongoDB implementation
      mongo-portfolio-item-repository.ts
    actions/
      profile-actions.ts      # Server Actions (composition root)
      portfolio-actions.ts
    ui/
      creator-profile-card.tsx
      portfolio-grid.tsx
      media-kit-preview.tsx
    event-handlers/
      on-contact-message-received.ts

The dependency rule flows one way: infrastructure depends on application, application depends on domain, domain depends on nothing. A use case like GetMediaKitData takes a repository interface as a constructor parameter — it has no idea if that repository talks to MongoDB, a file, or an in-memory test double.

This looks like a lot of files for what's ultimately a CRUD app with some extra logic. A useful framing from a colleague experienced with this pattern: "the cost of the extra files is constant, the cost of tangled dependencies is exponential." By month six, when AI features were added as a new module, the clean boundaries meant not modifying a single existing one.

Domain Entities With Real Rules#

Here's a simplified CreatorProfile:

typescript
// domain/entities/creator-profile.ts
import { Entity } from "@/lib/domain/entity";

interface CreatorProfileProps {
  organizationId: string;
  displayName: string;
  bio: string | null;
  instagramHandle: string | null;
  followerCount: number | null;
  isPublished: boolean;
  updatedAt: Date;
}

export class CreatorProfile extends Entity<CreatorProfileProps> {
  get displayName() { return this.props.displayName; }
  get bio() { return this.props.bio; }
  get isPublished() { return this.props.isPublished; }

  updateDetails(input: {
    displayName?: string;
    bio?: string | null;
    instagramHandle?: string | null;
  }): void {
    if (input.displayName !== undefined) {
      if (input.displayName.trim().length === 0) {
        throw new Error("Display name cannot be empty");
      }
      this.props.displayName = input.displayName.trim();
    }
    if ("bio" in input) {
      this.props.bio = input.bio ?? null;
    }
    if ("instagramHandle" in input) {
      this.props.instagramHandle = input.instagramHandle ?? null;
    }
    this.props.updatedAt = new Date();
  }

  publish(): void {
    // business rule: can't publish without a name
    if (!this.props.displayName) {
      throw new Error("Cannot publish profile without a display name");
    }
    this.props.isPublished = true;
    this.props.updatedAt = new Date();
  }

  unpublish(): void {
    this.props.isPublished = false;
    this.props.updatedAt = new Date();
  }
}

The entity knows its own invariants. The publish() method rejects invalid state. None of this touches React, Next.js, or MongoDB. Every business rule is testable with zero infrastructure in under a millisecond per test — a state machine with well-defined transitions.

Use Cases as Orchestrators#

A use case takes ports (interfaces) and orchestrates the domain:

typescript
// application/use-cases/update-creator-profile.ts
import type { CreatorProfileRepository } from "../ports/creator-profile-repository";

interface UpdateProfileInput {
  organizationId: string;
  displayName?: string;
  bio?: string | null;
  instagramHandle?: string | null;
}

export class UpdateCreatorProfile {
  constructor(private readonly profiles: CreatorProfileRepository) {}

  async execute(input: UpdateProfileInput): Promise<void> {
    const profile = await this.profiles.findByOrganizationId(
      input.organizationId
    );
    if (!profile) {
      throw new Error("Profile not found");
    }

    profile.updateDetails({
      displayName: input.displayName,
      bio: input.bio,
      instagramHandle: input.instagramHandle,
    });

    await this.profiles.save(profile);
  }
}

No HTTP, no database queries, no React. Just domain logic and port interfaces. The MongoDB implementation gets wired in at the infrastructure layer. Tests use an in-memory implementation. Same interface, swappable implementations — this is the whole point of ports and adapters.

Server Actions: The Composition Root#

This is where it all comes together. Server Actions are the only place where both the use case and the concrete infrastructure coexist:

typescript
// infrastructure/actions/profile-actions.ts
"use server";

import { auth } from "@/lib/auth";
import { getMongoDb } from "@/lib/infrastructure/mongodb";
import { MongoCreatorProfileRepository } from "../persistence/mongo-creator-profile-repository";
import { UpdateCreatorProfile } from "../../application/use-cases/update-creator-profile";
import { UpdateProfileSchema } from "../../domain/schemas/creator-profile.schema";

export async function updateProfileAction(
  _prevState: { error: string | null },
  formData: FormData
): Promise<{ error: string | null }> {
  const session = await auth();
  if (!session?.user?.organizationId) {
    return { error: "Not authenticated" };
  }

  const parsed = UpdateProfileSchema.safeParse({
    displayName: formData.get("displayName"),
    bio: formData.get("bio") || null,
    instagramHandle: formData.get("instagramHandle") || null,
  });

  if (!parsed.success) {
    return { error: parsed.error.issues[0].message };
  }

  try {
    const db = await getMongoDb();
    const profileRepo = new MongoCreatorProfileRepository(db);
    const useCase = new UpdateCreatorProfile(profileRepo);

    await useCase.execute({
      organizationId: session.user.organizationId,
      ...parsed.data,
    });

    return { error: null };
  } catch (err) {
    return { error: err instanceof Error ? err.message : "Update failed" };
  }
}

The action handles: auth, input validation (Zod), infrastructure wiring (new repo, new use case), execution, and error mapping. The use case and entity handle: business rules. Clean separation, and more importantly — testable separation. I can test the action with a real database in an integration test, and test the use case with an in-memory repo in a unit test. Different concerns at different speeds.

Multi-Tenant Routing#

Tenant apps live under /s/[app]/. BeautyGlam lives at /s/beautyglam/:

app/
  s/
    beautyglam/
      layout.tsx        # Auth check + org context + permission gate
      dashboard/
        page.tsx
        profile/
          page.tsx
          edit/page.tsx
      portfolio/
        page.tsx
        [itemId]/
          edit/page.tsx
      media-kit/
        page.tsx

The layout enforces that the user has access to this tenant app:

tsx
// app/s/beautyglam/layout.tsx
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { hasPermission } from "@/modules/rbac/infrastructure/actions";
import { BeautyGlamShell } from "@/modules/beauty-glam/infrastructure/ui/beautyglam-shell";

export default async function BeautyGlamLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();
  if (!session) redirect("/login");

  const canAccess = await hasPermission(session.user.organizationId, "beautyglam:access");
  if (!canAccess) redirect("/dashboard");

  return <BeautyGlamShell user={session.user}>{children}</BeautyGlamShell>;
}

Every page under /s/beautyglam/ gets auth and permission checks for free. No repeated auth code per page. The principle is the same on large multi-tenant platforms with many brands and storefronts: gate access at the layout level, not the page level.

Cross-Module Communication#

When a contact form submission comes in, the notification module needs to send an email. But the feature module should not import the notification module directly. Direct cross-module imports produce circular dependencies and modules that can't be tested or deployed independently — a problem that compounds badly on large codebases.

Domain events solve this:

typescript
// beauty-glam publishes an event
const event = new ContactMessageReceived({
  contactMessageId: message.id,
  organizationId: message.organizationId,
  senderName: message.senderName,
  messageSnippet: message.message.substring(0, 200),
});

await eventBus.publish(event);
typescript
// notification module subscribes — knows nothing about beauty-glam internals
eventBus.subscribe(ContactMessageReceived, async (event) => {
  await notificationService.sendEmail({
    to: getOrgEmail(event.payload.organizationId),
    template: "contact-message-received",
    data: event.payload,
  });
});

The publisher module and the subscriber module know nothing about each other's internals. The event bus is the shared contract. This feels like overkill with two modules. With seven modules, it pays for itself clearly.

My Take#

Hexagonal architecture in a Next.js SaaS feels over-engineered for the first two weeks. By week six, when you change the email provider without touching a single use case, or write tests for complex business logic without spinning up MongoDB, it pays for itself.

The investment I'd make from day one every time: define your domain entities before you write a single server action. The domain layer is the heart of the application — if it's anemic (just data bags with no behavior), you'll end up putting business logic in server actions, and that logic will be impossible to test in isolation.

The investment that paid less than I expected: elaborate module boundaries for very simple features. A feature with one entity, two server actions, and no cross-module communication doesn't need a full hexagonal module. I'm more pragmatic about this now. Not everything needs five directories and three layers. I wish I'd learned that before creating a full module structure for what turned out to be a 40-line feature.

Common Pitfalls#

Putting business logic in server actions. The action should wire things together and handle HTTP/auth concerns. "A profile must have a display name before it can be published" belongs in the entity. If your action is making decisions about what's allowed, push that logic down.

Importing directly between modules. It's tempting to import { sendEmail } from "@/modules/notification" from inside another module's action. It works today. It creates a coupling that makes both modules harder to test and change independently tomorrow. Pay the event bus cost upfront. The alternative — direct cross-module imports — produces dependency tangles that compound over years.

One server action per tiny operation. I initially created a separate action for every field change on the profile. Four weeks later I had fifteen tiny actions with identical auth and wiring boilerplate. Consolidate: one action per feature operation.

What I'd Do Differently#

If I started BeautyGlam today, I'd skip the full hexagonal structure for the simple CRUD modules and only reach for it when complexity warrants it. The portfolio module has basically one entity and two operations — it didn't need its own ports and use case classes. A server action calling the repository directly would have been fine.

I'd also invest more in integration tests earlier. My unit test coverage was strong from the start, but I caught several bugs in production that only manifested when the server action, the use case, and the MongoDB repo were wired together. The layers were individually correct but the mapping between them had subtle issues.

None of these ideas are new. Hexagonal architecture is twenty years old. Domain-driven design is older. What's new is how well they fit with the App Router's server-first model. The two design philosophies complement each other better than I expected. Server Components are natural read models. Server Actions are natural composition roots. The dependency rule flows the same direction as the rendering tree.

It just fits.

Building BeautyGlam: Architecture of a Multi-Tenant SaaS | Blog