Core-First Design
Why Vandor ships with almost nothing and lets you build what you need
The Core-First Philosophy
Vandor's base installation includes exactly three things:
- Logger -- structured logging
- Configuration -- typed config with validation
- Project structure -- the folder layout and uber-fx wiring
That is it. No HTTP server, no database, no cache, no message queue. Everything else arrives through Vpkg packages when you decide you need it.
This is not minimalism for its own sake. It is a design decision that makes Vandor genuinely versatile. The same core scaffolding can power a web API, a CLI tool, an event processor, an API gateway, or a background worker. The project type is determined by which packages you install, not by which template you chose at initialization time.
What Ships with Every Project
Logger
Every application needs logging. Vandor provides structured logging out of the box:
// Available everywhere through uber-fx injection
type Logger interface {
Info(msg string, fields ...Field)
Warn(msg string, fields ...Field)
Error(err error, msg string, fields ...Field)
Debug(msg string, fields ...Field)
}The logger is configured through config/base.yaml and supports different output formats (JSON for production, human-readable for development).
Configuration
Every application needs configuration. Vandor provides a typed configuration system with validation:
# config/base.yaml
app:
name: "my-service"
environment: "development"
log:
level: "info"
format: "json"Configuration uses a typed schema with validator/v10 for validation. You define your config struct with validation tags, and Vandor validates the configuration at startup before your application starts processing requests.
// internal/config/config.go
type Config struct {
App AppConfig `yaml:"app" validate:"required"`
Log LogConfig `yaml:"log" validate:"required"`
}
type AppConfig struct {
Name string `yaml:"name" validate:"required"`
Environment string `yaml:"environment" validate:"required,oneof=development staging production"`
}
type LogConfig struct {
Level string `yaml:"level" validate:"required,oneof=debug info warn error"`
Format string `yaml:"format" validate:"required,oneof=json text"`
}Config Validation
Configuration validation happens at startup, not at first use. If your base.yaml is missing a required field or has an invalid value, your application fails fast with a clear error message instead of crashing unexpectedly at runtime.
Project Structure
The folder layout, the uber-fx wiring, and the build configuration. This gives every project a consistent starting point:
myapp/
├── cmd/app/main.go # Entry point
├── internal/
│ ├── core/
│ │ ├── contracts/ # Shared domain contracts
│ │ ├── contexts/ # Empty -- you add contexts
│ │ └── _gen/ # Auto-generated wiring
│ ├── transport/ # Empty -- Vpkg adds transport
│ ├── infrastructure/ # Empty -- Vpkg adds infrastructure
│ ├── bootstrap/
│ │ ├── fx/ # uber-fx module definitions
│ │ └── runtime/ # Runner lifecycle management
│ └── config/ # Typed config structs
├── config/
│ ├── base.yaml # Base configuration
│ └── runner/ # Runner-specific overlays
├── vpkg.yaml # Empty package list
└── vpkg.lock # Empty lock fileThe Configuration System
Vandor's configuration system uses a layered approach: base configuration plus runner-specific overlays.
Base Configuration
config/base.yaml contains settings that apply to all runners:
app:
name: "hris-api"
environment: "development"
log:
level: "info"
format: "json"
# Vpkg packages add their own config sections here
# For example, after installing @official/http-humachi:
http:
port: 8080
host: "0.0.0.0"
read_timeout: "15s"
write_timeout: "15s"
# After installing @official/redis-cache:
redis:
address: "localhost:6379"
password: ""
db: 0Runner Overlays
Vandor supports multiple runners. Each runner is a different execution mode for your application. Runner-specific configuration goes in config/runner/<runner>.yaml and overrides values from base.yaml.
# config/runner/app.yaml -- overrides for the HTTP API runner
http:
port: 3000
# config/runner/worker.yaml -- overrides for the background worker runner
log:
level: "debug"
# config/runner/scheduler.yaml -- overrides for the cron scheduler runner
log:
level: "info"The final configuration for any runner is: base.yaml merged with runner/<runner>.yaml, where the runner overlay wins on conflicts.
The Runner Model
Vandor projects support four runners, each representing a different execution mode:
app Runner
The main application runner. Typically runs the HTTP server, serves API endpoints, and handles synchronous requests.
go run cmd/app/main.go --runner=appworker Runner
The background worker runner. Processes async tasks from a queue (like Asynq). No HTTP server needed.
go run cmd/app/main.go --runner=workerscheduler Runner
The cron scheduler runner. Runs scheduled tasks at configured intervals (like go-cron). No HTTP server, no task queue consumer needed.
go run cmd/app/main.go --runner=schedulerall Runner
Runs all runners in a single process. Useful for development and simple deployments where you do not want to manage multiple processes.
go run cmd/app/main.go --runner=allSingle Binary, Multiple Modes
All runners share the same binary. The --runner flag determines which components start. In development, use --runner=all to run everything in one process. In production, deploy separate instances for each runner for better scaling and isolation.
Dependency Injection with uber-fx
Vandor uses uber-fx for dependency injection. This is not optional -- it is built into the project structure.
Here is why uber-fx works well for this design:
Automatic Wiring
You declare what you need, and fx resolves the dependency graph:
// A use case declares its dependencies
type PlaceOrderDeps struct {
fx.In
OrderRepo domain.OrderRepository // fx finds the implementation
Logger logger.Logger // fx provides the logger
}
func NewPlaceOrder(deps PlaceOrderDeps) *PlaceOrder {
return &PlaceOrder{
orderRepo: deps.OrderRepo,
logger: deps.Logger,
}
}Module Composition
Each bounded context, Vpkg package, and infrastructure component provides an fx module. The bootstrap layer composes them:
// internal/bootstrap/fx/app.go
func NewApp(runner string) *fx.App {
modules := []fx.Option{
// Always present
config.Module,
logger.Module,
// Core context modules
identity.Module,
order.Module,
}
// Runner-specific modules
switch runner {
case "app":
modules = append(modules,
httptransport.Module, // HTTP server (from Vpkg)
persistence.Module, // Database (from Vpkg)
)
case "worker":
modules = append(modules,
asynq.WorkerModule, // Task consumer (from Vpkg)
persistence.Module, // Database (from Vpkg)
)
case "scheduler":
modules = append(modules,
cron.Module, // Cron scheduler (from Vpkg)
)
case "all":
modules = append(modules,
httptransport.Module,
persistence.Module,
asynq.WorkerModule,
cron.Module,
)
}
return fx.New(modules...)
}Lifecycle Management
uber-fx manages startup and shutdown order. When your application starts, fx initializes components in dependency order. When it shuts down, it tears them down in reverse order. This means database connections are established before repositories that need them, and closed after those repositories are done.
What Vandor Does Not Include (And Why)
No HTTP Server
Not every application is a web API. CLI tools, event processors, and batch jobs do not need an HTTP server. Installing an HTTP server when you do not need one means carrying dead code, unnecessary dependencies, and configuration for something you never use.
When you need HTTP, install it:
vandor vpkg add @official/http-humachiNo Database
Not every application needs a database. API gateways, in-memory processors, and some CLI tools work without persistent storage. Including a database driver by default would force a dependency on a specific database engine.
When you need a database, install it:
vandor vpkg add @official/entgo
vandor vpkg add @official/atlasNo Cache
Caching is an optimization, not a requirement. Adding it from the start is premature optimization. Your application should work correctly without a cache first, then add caching for performance.
When you need caching:
vandor vpkg add @official/redis-cacheNo Message Queue
Async processing adds operational complexity. Most applications start with synchronous request-response and add async processing when they hit a scaling wall or need to decouple processes.
When you need async processing:
vandor vpkg add @official/asynqBuilding Different Application Types
The core-first design means the same project structure supports radically different application types.
Web API
vandor new api-service
vandor add context identity
vandor add context order
vandor vpkg add @official/http-humachi
vandor vpkg add @official/entgo
vandor vpkg add @official/atlasResult: An HTTP API with database persistence, domain-driven bounded contexts, and OpenAPI documentation.
Background Worker
vandor new order-processor
vandor add context order
vandor vpkg add @official/asynq
vandor vpkg add @official/entgoResult: A background job processor that consumes tasks from a Redis queue and processes orders. No HTTP server.
Scheduled Job Service
vandor new report-generator
vandor add context reporting
vandor vpkg add @official/runner-go-cron
vandor vpkg add @official/entgo
vandor vpkg add @official/storage-s3Result: A cron-based service that generates reports on a schedule and uploads them to S3. No HTTP server, no task queue.
CLI Tool
vandor new data-importer
vandor add context importResult: A command-line tool with structured logging, configuration, and domain logic. No HTTP, no database, no cache, no queue. Just your business logic and the Go standard library.
Full-Stack Service
vandor new platform-service
vandor add context identity
vandor add context order
vandor add context notification
vandor vpkg add @official/http-humachi
vandor vpkg add @official/entgo
vandor vpkg add @official/atlas
vandor vpkg add @official/redis-cache
vandor vpkg add @official/asynq
vandor vpkg add @official/runner-go-cron
vandor vpkg add @official/storage-s3
vandor vpkg add @official/observabilityResult: A full platform service with HTTP API, database, caching, background jobs, scheduled tasks, file storage, and observability. Run with --runner=all in development, deploy separate app, worker, and scheduler instances in production.
Runtime Independence
This is a critical point that deserves emphasis: production does not need the Vandor binary.
After you scaffold your project, add your contexts, install your packages, and write your business logic, you have a standard Go application. It builds with go build. It runs as a native binary. It can be deployed anywhere Go binaries can run.
Vandor is a development tool. It helps you:
- Scaffold project structure (
vandor new) - Add bounded contexts (
vandor add context) - Install infrastructure packages (
vandor vpkg add) - Generate wiring code (
vandor sync) - Run package actions (
vandor vpkg exec)
None of these capabilities are needed at runtime. Your production binary has zero dependency on Vandor.
# Development: use Vandor CLI
vandor add context payment
vandor vpkg add @official/http-humachi
vandor sync
# Production: standard Go toolchain
go build -o myapp cmd/app/main.go
./myapp --runner=appThis means:
- No special runtime to install on production servers
- No framework overhead in your final binary
- Standard Go profiling, debugging, and monitoring tools work perfectly
- You can vendor your dependencies and build offline
- Docker images are minimal -- just your binary and config
Core-Only Testing
One of the most powerful consequences of core-first design is that your core layer can be tested without any infrastructure:
func TestPlaceOrder_RejectsEmptyItems(t *testing.T) {
mockRepo := &MockOrderRepository{}
uc := usecase.NewPlaceOrder(usecase.PlaceOrderDeps{
OrderRepo: mockRepo,
})
_, err := uc.Execute(context.Background(), usecase.PlaceOrderInput{
CustomerID: "cust-123",
Items: []usecase.PlaceOrderItemInput{}, // Empty!
})
assert.Error(t, err)
assert.False(t, mockRepo.SaveCalled) // Should not have attempted to save
}No database, no HTTP server, no Redis connection, no Docker containers. Just your business logic and a mock. Tests run in milliseconds.
This is possible because the core layer has zero dependencies on transport or infrastructure. If you find yourself needing a database connection to test a use case, your boundaries are wrong.
Comparison with Other Approaches
Traditional Frameworks (Rails, Django, Spring)
These frameworks include everything by default: web server, ORM, template engine, mailer, job queue, cache, and more. This is great for web applications, but terrible for anything else. Building a CLI tool with Rails means carrying an HTTP server you never use.
Vandor
Start with domain logic. Add infrastructure when your requirements demand it. This is not "batteries not included" -- it is "batteries sold separately, so you only carry the ones you need."
The tradeoff is clear: traditional frameworks get you to "Hello World" faster for web apps. Vandor gets you to a well-structured, testable, maintainable application faster for any type of Go service.