vandor.
Getting Started

Your First Project

Build a complete task management API from scratch with Vandor

Building a Task Management API

In this tutorial, you will build a REST API for managing tasks from start to finish. Along the way, you will learn how to think about domain modeling in Vandor, how the context structure works, how to wire up infrastructure, and how everything connects through uber-fx.

This is more detailed than the Quick Start. Take your time -- understanding these concepts will pay off as your projects grow.

Step 1: Create the Project

vandor new task-api --module github.com/yourname/task-api --tidy auto
cd task-api

This gives you a clean project with the core DDD structure, uber-fx dependency injection, structured logging, and YAML-based configuration. No HTTP server, no database, no extras -- just the foundation.

Your initial project looks like this:

task-api/
├── cmd/
│   └── app/
│       └── main.go
├── internal/
│   ├── core/
│   │   ├── contracts/
│   │   ├── contexts/
│   │   └── _gen/
│   ├── bootstrap/
│   │   ├── fx/
│   │   └── runtime/
│   └── config/
├── config/
│   ├── base.yaml
│   └── runner/
│       ├── app.yaml
│       └── worker.yaml
├── vpkg.yaml
└── vpkg.lock

Step 2: Design Your Domain

Before writing any code, think about what your application actually does from a business perspective. A task management system needs to handle:

  • Creating tasks with titles and descriptions
  • Assigning tasks to people
  • Tracking task status (pending, in progress, completed)
  • Listing and filtering tasks

We also want basic user identity so we know who is creating and being assigned tasks.

This gives us two bounded contexts:

  • task_management -- Everything about tasks: creating them, updating status, assigning them
  • identity -- Everything about users: who they are, authentication

Let us create them:

vandor add context task_management identity

This creates both contexts at once (Vandor supports space-separated arguments). Your project now has:

internal/core/contexts/
├── task_management/
│   ├── domain/
│   │   ├── entity/
│   │   ├── valueobject/
│   │   ├── repository.go
│   │   ├── read_repository.go
│   │   └── errors.go
│   ├── application/
│   │   ├── usecase/
│   │   └── service/
│   ├── module.go
│   └── module_gen.go
└── identity/
    ├── domain/
    │   ├── entity/
    │   ├── valueobject/
    │   ├── repository.go
    │   ├── read_repository.go
    │   └── errors.go
    ├── application/
    │   ├── usecase/
    │   └── service/
    ├── module.go
    └── module_gen.go

Notice the naming: task_management, not task or tasks. A context represents a business capability, not a database table. The task_management context might contain tasks, labels, assignments, and more -- it is the entire capability.

Step 3: Add Use Cases

Use cases are the actions your application can perform. Each use case has a single responsibility. Let us add the ones we need:

# Task management use cases
vandor add usecase task_management create_task
vandor add usecase task_management complete_task
vandor add usecase task_management assign_task
vandor add usecase task_management list_tasks

# Identity use cases
vandor add usecase identity register_user
vandor add usecase identity authenticate_user

Each command generates a use case file with a struct, an uber-fx compatible constructor (using fx.In deps struct), and an Execute method. For example, create_task.go looks something like this:

package usecase

import (
    "context"
    "go.uber.org/fx"
)

type CreateTaskDeps struct {
    fx.In
    // Your dependencies will be injected here
}

type CreateTask struct {
    deps CreateTaskDeps
}

func NewCreateTask(deps CreateTaskDeps) *CreateTask {
    return &CreateTask{deps: deps}
}

func (uc *CreateTask) Execute(ctx context.Context) error {
    // Implement your business logic here
    return nil
}

You will fill in the input/output types and the actual logic. The important thing is that the wiring is already done -- uber-fx will inject dependencies automatically.

Step 4: Add Domain Services

Services handle logic that does not belong to a single use case -- things like authorization rules or complex validation:

vandor add service identity auth_service

This creates a service in internal/core/contexts/identity/application/service/auth_service.go with the same uber-fx dependency injection pattern.

Step 5: Install Infrastructure Packages

Now that the domain layer is defined, let us add the infrastructure we need. We will use three official VPKG packages:

# HTTP server (Huma framework + Chi router)
vandor vpkg add @official/http-humachi

# Ent.go ORM for database access
vandor vpkg add @official/entgo

# Atlas for database migrations
vandor vpkg add @official/atlas

Each package installs into vpkg-managed directories:

  • @official/http-humachi sets up code in internal/transport/
  • @official/entgo and @official/atlas set up code in internal/infrastructure/

These directories are managed by VPKG. When you need to add HTTP handlers or configure the database, you will use vandor vpkg exec commands rather than editing the vpkg-managed files directly.

The internal/transport/ and internal/infrastructure/ directories are managed by VPKG. Do not edit files in these directories manually -- your changes may be overwritten when packages are updated. Use the provided vandor vpkg exec commands instead.

Step 6: Implement Your Domain Logic

Now let us write the actual business logic. Edit the entity and use case files that were generated.

Create the Task Entity

Edit internal/core/contexts/task_management/domain/entity/task.go:

package entity

import (
    "errors"
    "time"
)

type TaskStatus string

const (
    TaskStatusPending    TaskStatus = "pending"
    TaskStatusInProgress TaskStatus = "in_progress"
    TaskStatusCompleted  TaskStatus = "completed"
)

type Task struct {
    ID          string
    Title       string
    Description string
    Status      TaskStatus
    AssignedTo  string
    CreatedBy   string
    CreatedAt   time.Time
    UpdatedAt   time.Time
    CompletedAt *time.Time
}

func NewTask(title, description, createdBy string) (*Task, error) {
    if title == "" {
        return nil, errors.New("task title is required")
    }

    now := time.Now()
    return &Task{
        Title:       title,
        Description: description,
        Status:      TaskStatusPending,
        CreatedBy:   createdBy,
        CreatedAt:   now,
        UpdatedAt:   now,
    }, nil
}

func (t *Task) AssignTo(userID string) error {
    if userID == "" {
        return errors.New("user ID is required for assignment")
    }
    t.AssignedTo = userID
    t.UpdatedAt = time.Now()
    return nil
}

func (t *Task) MarkCompleted() error {
    if t.Status == TaskStatusCompleted {
        return errors.New("task is already completed")
    }
    t.Status = TaskStatusCompleted
    now := time.Now()
    t.UpdatedAt = now
    t.CompletedAt = &now
    return nil
}

Define the Repository Interface

Edit internal/core/contexts/task_management/domain/repository.go:

package domain

import (
    "context"
    "github.com/yourname/task-api/internal/core/contexts/task_management/domain/entity"
)

type TaskRepository interface {
    Create(ctx context.Context, task *entity.Task) error
    FindByID(ctx context.Context, id string) (*entity.Task, error)
    FindAll(ctx context.Context) ([]*entity.Task, error)
    Update(ctx context.Context, task *entity.Task) error
    Delete(ctx context.Context, id string) error
}

Implement the Create Task Use Case

Edit internal/core/contexts/task_management/application/usecase/create_task.go:

package usecase

import (
    "context"

    "github.com/google/uuid"
    "github.com/yourname/task-api/internal/core/contexts/task_management/domain"
    "github.com/yourname/task-api/internal/core/contexts/task_management/domain/entity"
    "go.uber.org/fx"
)

type CreateTaskInput struct {
    Title       string
    Description string
    CreatedBy   string
}

type CreateTaskOutput struct {
    TaskID string
}

type CreateTaskDeps struct {
    fx.In
    TaskRepo domain.TaskRepository
}

type CreateTask struct {
    deps CreateTaskDeps
}

func NewCreateTask(deps CreateTaskDeps) *CreateTask {
    return &CreateTask{deps: deps}
}

func (uc *CreateTask) Execute(ctx context.Context, input CreateTaskInput) (*CreateTaskOutput, error) {
    task, err := entity.NewTask(input.Title, input.Description, input.CreatedBy)
    if err != nil {
        return nil, err
    }

    task.ID = uuid.New().String()

    if err := uc.deps.TaskRepo.Create(ctx, task); err != nil {
        return nil, err
    }

    return &CreateTaskOutput{TaskID: task.ID}, nil
}

Notice how the use case depends on the TaskRepository interface, not a concrete implementation. The actual database adapter will be injected by uber-fx at runtime.

Step 7: Add HTTP Handlers

With @official/http-humachi installed, you can add HTTP handlers through VPKG exec commands:

vandor vpkg exec @official/http-humachi add:handler task_management create_task --method POST --path /api/tasks
vandor vpkg exec @official/http-humachi add:handler task_management list_tasks --method GET --path /api/tasks
vandor vpkg exec @official/http-humachi add:handler task_management complete_task --method PATCH --path /api/tasks/{id}/complete

These commands generate handler files in the transport layer that connect HTTP requests to your use cases. The handlers are automatically registered with the router.

Step 8: Configure Your Application

Edit config/base.yaml to set up your database and server:

app:
  name: task-api
  env: development

server:
  host: 0.0.0.0
  port: 8080

database:
  host: localhost
  port: 5432
  user: postgres
  password: password
  dbname: taskdb
  sslmode: disable

Runner-specific configuration goes in config/runner/. For example, config/runner/app.yaml can override settings for the app runner, and config/runner/worker.yaml for the worker runner.

If you have Docker, start PostgreSQL:

docker run -d \
  --name task-db \
  -e POSTGRES_PASSWORD=password \
  -e POSTGRES_DB=taskdb \
  -p 5432:5432 \
  postgres:16

Step 9: Sync and Run

Sync all generated code to wire everything together:

vandor sync all

This updates the generated files in internal/core/_gen/ to include all your contexts, use cases, and services in the uber-fx module registration.

Start the development server:

vandor dev:app

Your API is now running at http://localhost:8080.

Step 10: Test Your API

Create a task:

curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Implement user authentication",
    "description": "Add JWT-based auth to the API",
    "created_by": "550e8400-e29b-41d4-a716-446655440000"
  }'

List all tasks:

curl http://localhost:8080/api/tasks

Complete a task:

curl -X PATCH http://localhost:8080/api/tasks/{task-id}/complete

Step 11: Run for Production

When you are ready for production, use the production runner:

vandor run:app

This builds an optimized binary and runs it without hot reload. Remember, the built binary does not need the Vandor CLI -- it runs independently.

For background workers:

vandor run:worker

What You Built

Let us step back and look at what you have:

Two bounded contexts (task_management and identity) with clean separation of domain logic and application logic.

Domain entities with business rules baked in -- a Task knows how to validate itself and manage its own state transitions.

Use cases that orchestrate domain logic through repository interfaces, completely independent of database implementation.

Infrastructure (HTTP server, database ORM, migrations) installed on demand through VPKG, not baked into the project template.

Uber-fx dependency injection wiring everything together automatically -- no manual constructor chains, no global variables.

The entire architecture follows the dependency rule: domain code never imports infrastructure code. The domain layer defines interfaces (like TaskRepository), and the infrastructure layer provides implementations. Uber-fx connects them at startup.

Next Steps