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-apiThis 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.lockStep 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 identityThis 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.goNotice 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_userEach 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_serviceThis 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/atlasEach package installs into vpkg-managed directories:
@official/http-humachisets up code ininternal/transport/@official/entgoand@official/atlasset up code ininternal/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}/completeThese 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: disableRunner-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:16Step 9: Sync and Run
Sync all generated code to wire everything together:
vandor sync allThis 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:appYour 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/tasksComplete a task:
curl -X PATCH http://localhost:8080/api/tasks/{task-id}/completeStep 11: Run for Production
When you are ready for production, use the production runner:
vandor run:appThis 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:workerWhat 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.