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:
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 :
Server (SSR) : Loader runs, prefetches data, renders page with data
Client (hydration) : React hydrates with dehydrated cache, no flash of loading
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:
User navigates to /en/eser
Browser sends request to TanStack Start server (Nitro)
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()
}
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 ;
}
HTTP request to Go backend
GET http://services:8080/en/profiles/eser
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 )
}
Business logic executes
func Get ( ctx context . Context , repo Repository , localeCode , slug string ) ( * Profile , error ) {
return repo . GetBySlug ( ctx , localeCode , slug )
}
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
);
Response flows back
Database → Adapter → Business → HTTP Handler → Frontend → Query Cache
Page renders with data
function ProfilePage () {
const { data : profile } = useSuspenseQuery ( profileQueryOptions ( locale , slug ));
return < h1 > { profile . title } </ h1 > ;
}
HTML sent to browser
Server-rendered HTML with <script> tag containing dehydrated React Query state
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