Skip to main content

Backend Development

The Aya backend uses Go with hexagonal architecture to keep business logic pure, testable, and framework-independent.

Tech Stack

Language

Go 1.25 - Fast and reliable

HTTP Framework

Gin - Lightweight web framework

Database

PostgreSQL 16 with sqlc

Project Structure

apps/services/
├── pkg/
│   ├── api/
│   │   ├── business/        # Pure business logic (no dependencies)
│   │   │   ├── profiles/    # Profile domain
│   │   │   ├── stories/     # Story domain
│   │   │   └── auth/        # Authentication
│   │   └── adapters/        # External implementations
│   │       ├── http/        # HTTP handlers
│   │       ├── profiles/    # Profile repository
│   │       └── storage/     # File storage (S3)
│   └── ajan/                # Framework utilities
├── cmd/
│   ├── serve/               # HTTP server entrypoint
│   └── migrate/             # Database migrations
├── etc/data/default/
│   ├── migrations/          # SQL migrations
│   └── queries/             # SQL queries (sqlc)
└── sqlc.yaml                # sqlc configuration

Hexagonal Architecture

The backend follows strict hexagonal architecture (ports and adapters):
     HTTP Adapter


  [───────────────────]
  │ Business Logic   │  ← Pure Go, no dependencies
  │ (Domain)         │
  [───────────────────]


    DB Adapter

Business Layer (Core)

Pure Go with zero external dependencies:
pkg/api/business/profiles/types.go
package profiles

import "time"

// Domain model
type Profile struct {
    ID                string
    Slug              string
    Kind              string
    ProfilePictureURI *string
    CreatedAt         time.Time
    UpdatedAt         *time.Time
    DeletedAt         *time.Time

    // Localized fields
    Title       string
    Description string
}

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

import (
    "context"
    "aya.is/apps/services/pkg/ajan/results"
)

var ErrProfileNotFound = results.ErrNotFound("Profile not found")

func Get(
    ctx context.Context,
    repo Repository,
    localeCode string,
    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
}
Why Pure? Business logic can be tested with mocks, no database or HTTP server required.

Adapter Layer (Implementation)

Adapters implement the ports:
pkg/api/adapters/profiles/repository.go
package profiles

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

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

func (r *Repository) GetBySlug(
    ctx context.Context,
    localeCode string,
    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, " "),  // CRITICAL!
        Description:       strings.TrimRight(row.Description, " "),
    }, nil
}
CRITICAL: Always strings.TrimRight(value, " ") when mapping CHAR(12) fields. PostgreSQL pads CHAR with spaces.

Database Queries (sqlc)

Writing Queries

SQL queries are in etc/data/default/queries/*.sql:
etc/data/default/queries/profiles.sql
-- name: GetProfileBySlug :one
SELECT 
  p.id,
  p.slug,
  p.kind,
  p.profile_picture_uri,
  pt.locale_code,
  pt.title,
  pt.description
FROM "profile" p
JOIN "profile_tx" pt ON pt.profile_id = p.id
WHERE p.slug = sqlc.arg(slug)
  AND p.deleted_at IS NULL
  -- 3-tier locale fallback
  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 = sqlc.arg(locale_code) THEN 0
      WHEN ptx.locale_code = p.default_locale THEN 1
      ELSE 2
    END
    LIMIT 1
  )
LIMIT 1;

-- name: ListProfilesByKinds :many
SELECT
  p.id,
  p.slug,
  p.kind,
  pt.title,
  pt.description
FROM "profile" p
JOIN "profile_tx" pt ON pt.profile_id = p.id
WHERE p.kind = ANY(sqlc.arg(kinds)::text[])
  AND p.deleted_at IS NULL
  AND p.approved_at IS NOT NULL
  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 = sqlc.arg(locale_code) THEN 0
      WHEN ptx.locale_code = p.default_locale THEN 1
      ELSE 2
    END
    LIMIT 1
  )
ORDER BY p.created_at DESC
LIMIT sqlc.arg(limit)
OFFSET sqlc.arg(offset);

-- name: CreateProfile :one
INSERT INTO "profile" (
  "id",
  "slug",
  "kind",
  "created_at"
) VALUES (
  sqlc.arg(id),
  sqlc.arg(slug),
  sqlc.arg(kind),
  NOW()
)
RETURNING *;

Generate Go Code

Run after creating/modifying queries:
cd apps/services
make generate
# or: sqlc generate
This generates pkg/api/adapters/profiles/db.go with type-safe functions:
type Queries struct {
    db DBTX
}

func (q *Queries) GetProfileBySlug(ctx context.Context, arg GetProfileBySlugParams) (GetProfileBySlugRow, error)
func (q *Queries) ListProfilesByKinds(ctx context.Context, arg ListProfilesByKindsParams) ([]ListProfilesByKindsRow, error)
func (q *Queries) CreateProfile(ctx context.Context, arg CreateProfileParams) (Profile, error)

sqlc Configuration

sqlc.yaml
version: "2"
sql:
  - engine: "postgresql"
    queries: "etc/data/default/queries"
    schema: "etc/data/default/migrations"
    gen:
      go:
        package: "profiles"
        out: "pkg/api/adapters/profiles"
        sql_package: "pgx/v5"
        emit_json_tags: true
        emit_pointers_for_null_types: true

HTTP Layer (Gin)

Handler Structure

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

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

type ProfilesHandler struct {
    repo profiles.Repository
}

func NewProfilesHandler(repo profiles.Repository) *ProfilesHandler {
    return &ProfilesHandler{repo: repo}
}

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 {
        if errors.Is(err, profiles.ErrProfileNotFound) {
            c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal error"})
        return
    }

    c.JSON(http.StatusOK, profile)
}

func (h *ProfilesHandler) CreateProfile(c *gin.Context) {
    var req CreateProfileRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // Get authenticated user from context
    userID := c.GetString("user_id")

    profile, err := profiles.Create(c.Request.Context(), h.repo, profiles.CreateInput{
        Slug:        req.Slug,
        Kind:        req.Kind,
        Title:       req.Title,
        Description: req.Description,
        UserID:      userID,
    })
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusCreated, profile)
}

type CreateProfileRequest struct {
    Slug        string `json:"slug" binding:"required"`
    Kind        string `json:"kind" binding:"required"`
    Title       string `json:"title" binding:"required"`
    Description string `json:"description" binding:"required"`
}

Router Setup

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

import (
    "github.com/gin-gonic/gin"
)

func SetupRouter(
    profilesHandler *ProfilesHandler,
    storiesHandler *StoriesHandler,
    authMiddleware gin.HandlerFunc,
) *gin.Engine {
    r := gin.Default()

    // Public routes
    r.GET("/:locale/profiles/:slug", profilesHandler.GetProfile)
    r.GET("/:locale/stories", storiesHandler.ListStories)

    // Authenticated routes
    auth := r.Group("/")
    auth.Use(authMiddleware)
    {
        auth.POST("/:locale/profiles", profilesHandler.CreateProfile)
        auth.PUT("/:locale/profiles/:slug", profilesHandler.UpdateProfile)
        auth.DELETE("/:locale/profiles/:slug", profilesHandler.DeleteProfile)
    }

    return r
}

Authentication Middleware

pkg/api/adapters/http/middlewares/auth.go
package middlewares

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

func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.JSON(401, gin.H{"error": "Authorization header required"})
            c.Abort()
            return
        }

        token := strings.TrimPrefix(authHeader, "Bearer ")

        claims, err := auth.VerifyToken(token, jwtSecret)
        if err != nil {
            c.JSON(401, gin.H{"error": "Invalid token"})
            c.Abort()
            return
        }

        // Store user info in context
        c.Set("user_id", claims.UserID)
        c.Next()
    }
}

Error Handling

Sentinel Errors

pkg/api/business/profiles/errors.go
package profiles

import "aya.is/apps/services/pkg/ajan/results"

var (
    ErrProfileNotFound = results.ErrNotFound("Profile not found")
    ErrSlugTaken       = results.ErrConflict("Slug already taken")
    ErrInvalidSlug     = results.ErrValidation("Invalid slug format")
    ErrUnauthorized    = results.ErrForbidden("Unauthorized")
)

Error Response Pattern

func (h *ProfilesHandler) GetProfile(c *gin.Context) {
    profile, err := profiles.Get(ctx, h.repo, locale, slug)
    if err != nil {
        switch {
        case errors.Is(err, profiles.ErrProfileNotFound):
            c.JSON(404, gin.H{"error": "Profile not found"})
        case errors.Is(err, profiles.ErrUnauthorized):
            c.JSON(403, gin.H{"error": "Forbidden"})
        default:
            c.JSON(500, gin.H{"error": "Internal server error"})
        }
        return
    }
    c.JSON(200, profile)
}

Testing

Business Logic Tests

pkg/api/business/profiles/get_test.go
package profiles_test

import (
    "context"
    "testing"
    "time"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "aya.is/apps/services/pkg/api/business/profiles"
)

type MockRepository struct {
    mock.Mock
}

func (m *MockRepository) GetBySlug(ctx context.Context, locale, slug string) (*profiles.Profile, error) {
    args := m.Called(ctx, locale, slug)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*profiles.Profile), args.Error(1)
}

func TestGet_Success(t *testing.T) {
    mockRepo := new(MockRepository)
    ctx := context.Background()

    expected := &profiles.Profile{
        ID:          "prof-123",
        Slug:        "eser",
        Kind:        "individual",
        Title:       "Eser Ozvataf",
        Description: "Software Engineer",
    }

    mockRepo.On("GetBySlug", ctx, "en", "eser").Return(expected, nil)

    result, err := profiles.Get(ctx, mockRepo, "en", "eser")

    assert.NoError(t, err)
    assert.Equal(t, expected, result)
    mockRepo.AssertExpectations(t)
}

func TestGet_DeletedProfile(t *testing.T) {
    mockRepo := new(MockRepository)
    ctx := context.Background()

    deletedTime := time.Now()
    deleted := &profiles.Profile{
        ID:        "prof-123",
        Slug:      "deleted",
        DeletedAt: &deletedTime,
    }

    mockRepo.On("GetBySlug", ctx, "en", "deleted").Return(deleted, nil)

    result, err := profiles.Get(ctx, mockRepo, "en", "deleted")

    assert.Error(t, err)
    assert.ErrorIs(t, err, profiles.ErrProfileNotFound)
    assert.Nil(t, result)
}

HTTP Handler Tests

pkg/api/adapters/http/profiles_test.go
package http_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
)

func TestGetProfile_Success(t *testing.T) {
    gin.SetMode(gin.TestMode)

    mockRepo := new(MockProfileRepository)
    handler := NewProfilesHandler(mockRepo)

    router := gin.New()
    router.GET("/:locale/profiles/:slug", handler.GetProfile)

    mockRepo.On("GetBySlug", mock.Anything, "en", "eser").Return(
        &profiles.Profile{ID: "1", Slug: "eser", Title: "Eser"},
        nil,
    )

    req := httptest.NewRequest("GET", "/en/profiles/eser", nil)
    w := httptest.NewRecorder()

    router.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    var response profiles.Profile
    json.Unmarshal(w.Body.Bytes(), &response)
    assert.Equal(t, "eser", response.Slug)
}
Run tests:
cd apps/services
make test
make test-cov  # With coverage

Common Patterns

Creating a New Feature

1

Define business types

pkg/api/business/myfeature/types.go
package myfeature

type MyEntity struct {
    ID   string
    Name string
}

type Repository interface {
    Get(ctx context.Context, id string) (*MyEntity, error)
}
2

Implement business logic

pkg/api/business/myfeature/get.go
func Get(ctx context.Context, repo Repository, id string) (*MyEntity, error) {
    return repo.Get(ctx, id)
}
3

Write SQL query

etc/data/default/queries/myfeature.sql
-- name: GetMyEntity :one
SELECT * FROM my_entity WHERE id = sqlc.arg(id);
Generate: make generate
4

Implement repository adapter

pkg/api/adapters/myfeature/repository.go
type Repository struct {
    queries *Queries
}

func (r *Repository) Get(ctx context.Context, id string) (*myfeature.MyEntity, error) {
    row, err := r.queries.GetMyEntity(ctx, id)
    // map and return
}
5

Create HTTP handler

pkg/api/adapters/http/myfeature.go
func (h *MyFeatureHandler) Get(c *gin.Context) {
    id := c.Param("id")
    entity, err := myfeature.Get(c.Request.Context(), h.repo, id)
    c.JSON(200, entity)
}
6

Register route

router.GET("/myentities/:id", handler.Get)

Next Steps

Database Guide

Deep dive into PostgreSQL, migrations, and sqlc

Frontend Development

Build UI to consume the API

Architecture

Understand hexagonal architecture in depth

Deployment

Deploy to production

Build docs developers (and LLMs) love