Skip to main content

Architecture

Aya follows a hexagonal architecture (also known as ports and adapters) in the backend, combined with a modern React frontend using server-side rendering and TanStack Query for data synchronization.

System Architecture

Monorepo Structure: Aya is organized as a monorepo with clear separation between frontend (apps/webclient), backend (apps/services), and shared resources.

High-Level Overview

┌────────────────────────────────────────┐
│          Browser (Client)                 │
│  - TanStack Router (file-based routing)   │
│  - React 19 + TanStack Query              │
│  - CSS Modules + Tailwind                 │
└────────────────┬───────────────────────┘

                 │ HTTP/JSON

┌────────────────┤├───────────────────────┐
│   Frontend (apps/webclient)              │
│  - Deno Runtime                          │
│  - TanStack Start (SSR + Nitro Server)   │
│  - Vite (bundler)                        │
│  - Backend Facade (src/modules/backend)  │
└────────────────┬───────────────────────┘

                 │ REST API

┌────────────────┤├───────────────────────┐
│   Backend (apps/services)                │
│                                          │
│  ┌─────────────────────────────────┐ │
│  │  HTTP Adapter (pkg/api/adapters/http) │ │
│  └──────────────┬───────────────────┘ │
│                 │                       │
│  ┌──────────────┤├───────────────────┐ │
│  │  Business Logic (pkg/api/business)   │ │
│  │  - Pure Go, no dependencies          │ │
│  │  - Domain models & use cases         │ │
│  └──────────────┬───────────────────┘ │
│                 │                       │
│  ┌──────────────┤├───────────────────┐ │
│  │  DB Adapter (pkg/api/adapters/*)     │ │
│  │  - sqlc generated queries            │ │
│  │  - External integrations             │ │
│  └──────────────┬───────────────────┘ │
└─────────────────┤├───────────────────────┘

                 │ SQL

┌────────────────┤├───────────────────────┐
│      PostgreSQL 16 Database              │
└────────────────────────────────────────┘

Backend: Hexagonal Architecture

The backend strictly follows hexagonal architecture to keep business logic pure and testable.

Directory Structure

apps/services/
├── pkg/
   ├── api/
   ├── business/              # CORE: Pure business logic
   ├── profiles/          # Profile domain
   ├── stories/           # Story domain
   ├── auth/              # Authentication domain
   ├── discussions/       # Discussion domain
   └── mailbox/           # Messaging domain

   └── adapters/              # ADAPTERS: External implementations
       ├── http/              # HTTP handlers (Gin)
       ├── profiles/          # Database queries (sqlc)
       ├── storage/           # File storage (S3)
       ├── telegram/          # Telegram bot
       ├── github/            # GitHub API
       ├── linkedin/          # LinkedIn API
       └── resend/            # Email service

   └── ajan/                  # Framework utilities
       ├── httpfx/            # HTTP server helpers
       ├── connfx/            # Database connection
       ├── configfx/          # Configuration loading
       ├── logfx/             # Structured logging
       └── workerfx/          # Background workers

├── etc/
   ├── data/default/
   ├── migrations/        # SQL migrations (goose)
   └── queries/           # SQL queries (sqlc)
   └── locales/               # i18n message catalogs

├── cmd/                       # Entrypoints
   ├── server/                # HTTP server
   └── worker/                # Background workers

├── config.json                # Base configuration
└── sqlc.yaml                  # sqlc code generation config

Business Layer (Core)

The business layer is pure Go with no external dependencies (no HTTP, no database, no frameworks).
pkg/api/business/profiles/types.go
package profiles

// Domain model - pure Go struct
type Profile struct {
    ID                string
    Slug              string
    Kind              string // "individual", "organization", "product"
    ProfilePictureURI *string
    Pronouns          *string
    CreatedAt         time.Time
    UpdatedAt         *time.Time

    // Translations (localized fields)
    Title       string
    Description string
}

// Port (interface) - business logic defines what it needs
type Repository interface {
    GetBySlug(ctx context.Context, localeCode, slug string) (*Profile, error)
    List(ctx context.Context, localeCode string, kinds []string, limit int) ([]*Profile, error)
    Create(ctx context.Context, profile *Profile) error
    Update(ctx context.Context, profile *Profile) error
}
Business logic uses these interfaces:
pkg/api/business/profiles/get.go
package profiles

import "context"

// Pure business function - no framework dependencies
func Get(ctx context.Context, repo Repository, localeCode, slug string) (*Profile, error) {
    profile, err := repo.GetBySlug(ctx, localeCode, slug)
    if err != nil {
        return nil, err
    }

    // Business rule: Don't show deleted profiles
    if profile.DeletedAt != nil {
        return nil, ErrProfileNotFound
    }

    return profile, nil
}
Testability: Business logic can be unit tested with mock repositories, no database required.

Adapter Layer (Implementation)

Adapters implement the ports (interfaces) defined in business logic.

HTTP Adapter

pkg/api/adapters/http/profiles.go
package http

import (
    "github.com/gin-gonic/gin"
    "aya.is/apps/services/pkg/api/business/profiles"
)

type ProfilesHandler struct {
    repo profiles.Repository
}

func (h *ProfilesHandler) GetProfile(c *gin.Context) {
    locale := c.Param("locale")
    slug := c.Param("slug")

    // Call business logic
    profile, err := profiles.Get(c.Request.Context(), h.repo, locale, slug)
    if err != nil {
        c.JSON(404, gin.H{"error": "Profile not found"})
        return
    }

    c.JSON(200, profile)
}

Database Adapter

Database adapters use sqlc for type-safe SQL:
etc/data/default/queries/profiles.sql
-- name: GetProfileBySlug :one
SELECT 
  p.id, p.slug, p.kind, p.profile_picture_uri,
  pt.title, pt.description
FROM "profile" p
JOIN "profile_tx" pt ON pt.profile_id = p.id
WHERE p.slug = sqlc.arg(slug)
  AND pt.locale_code = (
    -- 3-tier fallback: requested locale → profile default → any available
    SELECT ptx.locale_code FROM "profile_tx" ptx
    WHERE ptx.profile_id = p.id
    ORDER BY CASE
      WHEN ptx.locale_code = sqlc.arg(locale_code) THEN 0
      WHEN ptx.locale_code = p.default_locale THEN 1
      ELSE 2
    END
    LIMIT 1
  )
LIMIT 1;
sqlc generates type-safe Go code:
pkg/api/adapters/profiles/repository.go
package profiles

import (
    "context"
    "aya.is/apps/services/pkg/api/business/profiles"
)

type Repository struct {
    queries *Queries // sqlc generated
}

func (r *Repository) GetBySlug(ctx context.Context, localeCode, slug string) (*profiles.Profile, error) {
    row, err := r.queries.GetProfileBySlug(ctx, GetProfileBySlugParams{
        Slug:       slug,
        LocaleCode: localeCode,
    })
    if err != nil {
        return nil, err
    }

    // Map database model to business model
    return &profiles.Profile{
        ID:                row.ID,
        Slug:              row.Slug,
        Kind:              row.Kind,
        ProfilePictureURI: row.ProfilePictureURI,
        Title:             strings.TrimRight(row.Title, " "),
        Description:       strings.TrimRight(row.Description, " "),
    }, nil
}
CRITICAL: Always strings.TrimRight(value, " ") when mapping CHAR(12) locale codes to Go strings. PostgreSQL pads CHAR fields with spaces.

Frontend: TanStack Start + React Query

The frontend uses a modern React architecture with SSR and optimistic caching.

Directory Structure

apps/webclient/
├── src/
   ├── routes/                    # File-based routing (TanStack Router)
   ├── __root.tsx             # Root layout
   ├── index.tsx              # Redirect to /en
   └── $locale/               # Locale-prefixed routes
       ├── index.tsx          # Home page
       ├── articles/          # Article listing
       ├── mailbox.tsx        # Messaging
       └── $slug/             # Profile routes
           ├── index.tsx      # Profile view
           ├── qa/            # Q&A section
           ├── settings/      # Profile settings
           └── stories/       # Profile stories

   ├── modules/
   ├── backend/               # API client facade
   ├── backend.ts         # Centralized backend object
   ├── queries.ts         # TanStack Query options
   ├── fetcher.ts         # HTTP client
   └── profiles/
   └── stories/
   ├── i18n/                  # Internationalization
   └── auth/                  # Authentication context

   ├── components/
   ├── ui/                    # shadcn/ui primitives
   ├── page-layouts/          # Page wrappers
   ├── forms/                 # Form components
   └── widgets/               # Composite widgets

   ├── lib/                       # Utilities
   ├── auth/                  # Auth helpers
   ├── schemas/               # Validation (Zod)
   └── mdx.tsx                # MDX compilation

   ├── messages/                  # i18n JSON files
   ├── en.json
   ├── tr.json
   └── ...

   ├── config.ts                  # Site configuration
   ├── router.tsx                 # Router setup
   └── styles.css                 # Global styles

├── deno.json                      # Deno configuration
├── package.json                   # npm dependencies
└── vite.config.ts                 # Vite bundler config

Backend Facade Pattern

All API calls go through a centralized backend object:
src/modules/backend/backend.ts
// Centralized API client
import { getProfile } from "./profiles/get-profile";
import { getStories } from "./stories/get-stories";
import { createStory } from "./stories/create-story";
// ... 100+ API functions

export const backend = {
  // Profiles
  getProfile,
  getProfilesByKinds,
  createProfile,
  updateProfile,

  // Stories
  getStories,
  getStory,
  createStory,
  updateStory,

  // Discussions
  getStoryDiscussion,
  createComment,
  voteComment,

  // ... all API methods
};
Usage in components:
import { backend } from "@/modules/backend/backend.ts";

const profile = await backend.getProfile("en", "eser");
const stories = await backend.getStoriesByKinds("en", ["article"]);
Benefits:
  • Single import point
  • Easy to mock for testing
  • Auto-completion in IDE
  • Consistent error handling

React Query with SSR

Aya uses TanStack Query for data fetching with server-side rendering:
src/router.tsx
import { QueryClient } from "@tanstack/react-query";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,  // 1 minute
      retry: 2,
    },
  },
});

const router = createRouter({ routeTree });

// Automatic dehydrate/hydrate for SSR
setupRouterSsrQueryIntegration({ router, queryClient });

Query Option Factories

Centralized query configurations in src/modules/backend/queries.ts:
src/modules/backend/queries.ts
import { queryOptions } from "@tanstack/react-query";
import { backend } from "./backend";

// Profile query
export const profileQueryOptions = (locale: string, slug: string) =>
  queryOptions({
    queryKey: ["profile", locale, slug],
    queryFn: () => backend.getProfile(locale, slug),
  });

// Stories by kind
export const storiesByKindsQueryOptions = (locale: string, kinds: string[]) =>
  queryOptions({
    queryKey: ["stories", locale, { kinds }],
    queryFn: () => backend.getStoriesByKinds(locale, kinds),
  });

Route Loader Pattern

src/routes/$locale/articles/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query";
import { storiesByKindsQueryOptions } from "@/modules/backend/queries";
import { QueryError } from "@/components/query-error";

export const Route = createFileRoute("/$locale/articles/")(
  {
    // 1. Loader: prefetch into cache (runs on server for SSR)
    loader: async ({ params, context }) => {
      const { locale } = params;
      await context.queryClient.ensureQueryData(
        storiesByKindsQueryOptions(locale, ["article"])
      );
      return { locale };
    },

    // 2. Error component for query failures
    errorComponent: QueryError,
  },
);

function Articles() {
  const { locale } = Route.useLoaderData();

  // 3. Read from hydrated cache (no loading state on initial render)
  const { data: articles } = useSuspenseQuery(
    storiesByKindsQueryOptions(locale, ["article"])
  );

  return (
    <div>
      {articles.map((article) => (
        <ArticleCard key={article.id} article={article} />
      ))}
    </div>
  );
}

export default Articles;
Flow:
  1. Server (SSR): Loader runs, prefetches data, renders page with data
  2. Client (hydration): React hydrates with dehydrated cache, no flash of loading
  3. Navigation: Client-side transitions use cached data if fresh, background refetch if stale
Use ensureQueryData when you need the data in head() for meta tags. Use prefetchQuery for optional data that only components need.

Data Flow Example

Let’s trace a profile page request:
1

User navigates to /en/eser

Browser sends request to TanStack Start server (Nitro)
2

Route loader prefetches data

loader: async ({ params, context }) => {
  const profile = await context.queryClient.ensureQueryData(
    profileQueryOptions("en", "eser")
  );
  // Data is now in cache AND returned for head()
}
3

Backend facade calls API

// src/modules/backend/profiles/get-profile.ts
export async function getProfile(locale: string, slug: string) {
  const res = await fetcher.get(`/${locale}/profiles/${slug}`);
  return res.data;
}
4

HTTP request to Go backend

GET http://services:8080/en/profiles/eser
5

HTTP adapter receives request

func (h *ProfilesHandler) GetProfile(c *gin.Context) {
    locale := c.Param("locale")
    slug := c.Param("slug")
    profile, err := profiles.Get(c.Request.Context(), h.repo, locale, slug)
    c.JSON(200, profile)
}
6

Business logic executes

func Get(ctx context.Context, repo Repository, localeCode, slug string) (*Profile, error) {
    return repo.GetBySlug(ctx, localeCode, slug)
}
7

Database adapter queries PostgreSQL

SELECT p.id, p.slug, pt.title, pt.description
FROM "profile" p
JOIN "profile_tx" pt ON pt.profile_id = p.id
WHERE p.slug = 'eser'
  AND pt.locale_code = (
    SELECT ptx.locale_code FROM "profile_tx" ptx
    WHERE ptx.profile_id = p.id
    ORDER BY CASE WHEN ptx.locale_code = 'en' THEN 0 ELSE 1 END
    LIMIT 1
  );
8

Response flows back

Database → Adapter → Business → HTTP Handler → Frontend → Query Cache
9

Page renders with data

function ProfilePage() {
  const { data: profile } = useSuspenseQuery(profileQueryOptions(locale, slug));
  return <h1>{profile.title}</h1>;
}
10

HTML sent to browser

Server-rendered HTML with <script> tag containing dehydrated React Query state
11

Client hydrates

React rehydrates with cached data, no loading spinner, instant interactivity

Key Design Patterns

1. Hexagonal Architecture (Backend)

Problem: Tight coupling to frameworks makes code hard to test and change. Solution: Business logic is pure, adapters are swappable.
// Business logic depends on interfaces (ports)
type Repository interface {
    GetBySlug(ctx context.Context, locale, slug string) (*Profile, error)
}

// Adapters implement interfaces
type PostgresRepository struct { /* ... */ }
func (r *PostgresRepository) GetBySlug(...) { /* ... */ }

2. Backend Facade (Frontend)

Problem: API calls scattered across components, inconsistent patterns. Solution: Single import point for all API operations.
import { backend } from "@/modules/backend/backend.ts";
// All API methods available from one object

3. Query Option Factories (Frontend)

Problem: Duplicate query key and fetcher logic across routes and components. Solution: Centralized query configurations.
// Define once in queries.ts
export const profileQueryOptions = (locale: string, slug: string) => queryOptions({ ... });

// Use in loader
await context.queryClient.ensureQueryData(profileQueryOptions(locale, slug));

// Use in component
const { data } = useSuspenseQuery(profileQueryOptions(locale, slug));

4. 3-Tier Locale Fallback (Database)

Problem: Missing translations shouldn’t hide content. Solution: SQL subquery with CASE-based priority.
AND pt.locale_code = (
  SELECT ptx.locale_code FROM "profile_tx" ptx
  WHERE ptx.profile_id = p.id
  ORDER BY CASE
    WHEN ptx.locale_code = @requested THEN 0  -- Preferred
    WHEN ptx.locale_code = @default THEN 1    -- Fallback
    ELSE 2                                     -- Any available
  END
  LIMIT 1
)
NEVER use 2-tier fallback (WHERE locale IN (@requested, @default)) - it drops entities when default_locale doesn’t match any translation.

Next Steps

Profiles

Learn about the profile system and user management

Stories

Understand content publishing and story types

Internationalization

Deep dive into i18n and the 3-tier fallback system

Backend Development

Start building business logic and adapters

Build docs developers (and LLMs) love