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
}
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 inetc/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
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)
}
cd apps/services
make test
make test-cov # With coverage
Common Patterns
Creating a New Feature
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)
}
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)
}
Write SQL query
etc/data/default/queries/myfeature.sql
-- name: GetMyEntity :one
SELECT * FROM my_entity WHERE id = sqlc.arg(id);
make generateImplement 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
}
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)
}
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