ASAASA Standard
Active Phase 1Slice Architecture

ASA Architecture — Domain Slices & Boundaries

Architecture · ARCH-01, ARCH-02, ARCH-03, ARCH-04, ARCH-05, ARCH-06 · ARCH-07, ARCH-09 · Priority: P0

Why It Matters

AI tools generate code without architectural awareness. Every prompt adds files wherever seems convenient — business logic in pages, shared utilities mixed with domain code, imports crossing boundaries freely. After 50-100 prompts, one change breaks three unrelated features because everything is connected to everything.

The ASA architecture pattern prevents this by enforcing clear boundaries: each feature lives in its own domain slice, pages are thin routing wrappers, and cross-domain coupling is blocked.

Priority: P0 — Without architecture enforcement, AI-generated code becomes unmaintainable. Each new prompt increases the probability of cascading failures.

Affected Stack: Next.js (App Router), React


ARCH-01 — Business logic in domains/

The Problem

AI tools put business logic wherever the current prompt lands — in page files, API routes, shared utilities, or root-level files. This makes the code impossible to reason about: where does billing logic live? Everywhere.

// ❌ Business logic scattered across the app
app/dashboard/page.tsx    → 200 LOC with API calls, state management, business rules
shared/utils.ts           → billing calculations mixed with string helpers
app/api/webhook/route.ts  → auth checks mixed with billing fulfillment

The Fix

All business logic belongs in src/domains/{feature}/:

// ✅ ASA architecture — business logic in domain slices
domains/
  billing/
    lib/checkout.ts        → checkout logic
    lib/webhook-handler.ts → webhook processing
    ui/PricingCard.tsx      → billing UI components
  auth/
    lib/session.ts         → session management
    lib/middleware.ts       → auth middleware

ARCH-02 — domains/ directory exists

The Problem

Without a domains/ directory, there is no designated place for business logic. Code spreads across lib/, utils/, helpers/, components/, and page files — with no convention to follow.

The Fix

Create src/domains/ as the single home for all business logic. Each domain gets its own subdirectory with lib/ for logic and ui/ for components.


ARCH-03 — No cross-domain imports

The Problem

When domains/billing/ imports from domains/auth/, changing auth can break billing. This invisible coupling is the root cause of "I changed one thing and broke everything."

// ❌ Cross-domain import creates hidden coupling
// In domains/billing/lib/checkout.ts
import { getSession } from '@/domains/auth/lib/session';

The Fix

Domains import only from shared/. If two domains need the same functionality, it lives in shared/:

// ✅ Both domains import from shared
// In domains/billing/lib/checkout.ts
import { getSession } from '@/shared/auth/session';

ARCH-04 — Thin pages (< 80 LOC)

The Problem

AI tools put everything into page files — data fetching, state management, business logic, UI rendering. A 300-line page file is a maintenance nightmare: changing any part risks breaking every other part.

The Fix

Pages are thin routing wrappers. Max 80 lines. They import from domains and compose — never implement:

// ✅ Thin page — routing + layout only
import { DashboardView } from '@/domains/dashboard/ui/DashboardView';

export default function DashboardPage() {
  return <DashboardView />;
}

ARCH-05 — shared/ has no business logic

The Problem

shared/ becomes a dumping ground for code that "doesn't belong anywhere." Over time, billing calculations, auth checks, and admin logic accumulate in shared/utils/ — creating a hidden dependency hub that couples everything.

The Fix

shared/ contains only cross-cutting infrastructure: database clients, type definitions, UI primitives, layout components. Any logic specific to billing, auth, or admin belongs in its domain slice.


ARCH-06 — File size limit (> 500 LOC)

The Problem

AI tools add code to the most convenient location — typically the file that already contains related logic. After 50-100 prompts, a single file grows to 500, 800, even 1000+ lines. At that point, the AI itself can't reason about the file anymore — it loses context, introduces contradictions, and breaks existing logic.

This is one of the primary causes of the "AI wall" — the point where the AI tool stops being able to make productive changes.

The Fix

Split oversized files into smaller, focused modules. Each file should have a single responsibility:

// ❌ Before: one 800-line god component
// domains/billing/checkout.ts (800 lines)
// - checkout logic
// - webhook handling
// - validation
// - email notifications

// ✅ After: four focused files
// domains/billing/checkout/handler.ts (~150 lines) — checkout flow
// domains/billing/webhook/handler.ts (~120 lines) — webhook processing
// domains/billing/checkout/schemas.ts (~60 lines) — validation
// domains/billing/checkout/notifications.ts (~80 lines) — emails

Hard limit: Files over 500 LOC fail the check. Aim for under 300 LOC per file.


ARCH-07 — No empty slice directories

The Problem

AI tools sometimes create a domain slice directory but leave it empty — either because the generation was interrupted, the feature was abandoned mid-prompt, or the scaffolding was created without follow-up implementation. Empty directories clutter the codebase and give the false impression that a feature exists.

// ❌ Empty slice — no code, just an abandoned directory
domains/
  billing/
    subscribe/
      (empty)
    checkout/
      handler.ts
      types.ts

The Fix

Every slice directory inside domains/*/ must contain at least one .ts or .tsx file. If a slice is no longer needed, delete the directory. If it's planned for later, don't create it yet.

// ✅ Every slice has code
domains/
  billing/
    checkout/
      CheckoutForm.tsx
      useCheckout.ts
      actions.ts
      types.ts

Use npx @vibecodiq/cli create-slice billing/subscribe to scaffold a new slice with the correct file structure.


ARCH-09 — Ports use only dynamic imports

The Problem

When one domain needs to call another domain's logic, the correct pattern is a port — a wrapper in shared/ports/ that uses dynamic import(). But if the port uses a top-level static import from domains/, it creates the same coupling that the port was supposed to prevent.

// ❌ Port with static import — defeats the purpose
// shared/ports/shortlinks.ts
import { createShortlinkAction } from '@/domains/shortlinks/create/actions';

export async function createShortlinkPort(url: string, userId: string) {
  return createShortlinkAction({ url, userId });
}

The Fix

Ports must use dynamic import() inside the function body. Type-only imports (import type) are allowed since they don't create runtime coupling.

// ✅ Port with dynamic import — proper decoupling
// shared/ports/shortlinks.ts
import 'server-only'
import type { ShortlinkResult } from '@/shared/types/shortlink'

export async function createShortlinkPort(
  url: string,
  userId: string
): Promise<ShortlinkResult> {
  const { createShortlinkAction } = await import(
    '@/domains/shortlinks/create/actions'
  );
  return createShortlinkAction({ url, userId });
}

The calling domain imports the port from shared/ports/, never from the target domain directly. This keeps domains decoupled while allowing controlled cross-domain logic calls.


Related Checks


Is your app safe? Run Free Scan →