Migrating from Spring Boot to Go and YDB Serverless: A Typing Trainer’s Journey
A developer with Java and Spring Boot experience migrated the TypeStep typing trainer to Yandex Cloud Serverless. Ditching paid VMs and ECS-like services led to choosing Go for the backend and YDB over PostgreSQL. Result: cold container starts in milliseconds, staying within the free tier with 1 million Request Units per month.
Go compiles into a static binary without JVM or JIT warm-up. Containers launch instantly—critical for Serverless Containers where each request may spawn a new instance. Spring Boot with GraalVM requires extra configuration, which didn’t fit the tight budget.
YDB Serverless replaced PostgreSQL due to cost. 1 million RU/month is free, but unit calculation remained partially unclear without deep documentation review.
Architecture Without Unnecessary Layers
The frontend runs on Next.js with static generation, stored in Object Storage. The backend is Go in Docker on Serverless Containers. The database is YDB Serverless. Requests flow directly from the browser: static assets from storage, API calls to containers. No API Gateway reduces latency and costs.
// Example enum for YDB columns
type StageType string
const (
Lowercase StageType = "lowercase"
Uppercase StageType = "uppercase"
Punctuation StageType = "punctuation"
Numbers StageType = "numbers"
)
func (s *StageType) Scan(value interface{}) error {
return ScanStringEnum(s, value, "StageType")
}
func (s *StageType) Value() (driver.Value, error) {
return string(*s), nil
}
After moving from JPA, manual schema design and YQL queries feel like a regression. Each field requires DECLARE, typed parameters, and manual scanning.
Repositories and YQL Queries
Repositories use YQL with transactions. Example of creating a record:
func (r *ItemRepository) CreateItem(ctx context.Context, item Item) (*Item, error) {
var result *Item
err := r.txManager.DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
res, err := tx.Execute(ctx, `
DECLARE $id AS Utf8;
DECLARE $name AS Utf8;
DECLARE $status AS Utf8;
DECLARE $created_at AS Datetime;
INSERT INTO items (id, name, status, created_at)
VALUES ($id, $name, $status, $created_at)
RETURNING id, name, status, created_at;
`,
table.NewQueryParameters(
table.ValueParam("$id", types.UTF8Value(item.Id)),
table.ValueParam("$name", types.UTF8Value(item.Name)),
table.ValueParam("$status", types.UTF8Value(string(item.Status))),
table.ValueParam("$created_at", types.DatetimeValueFromTime(item.CreatedAt)),
),
)
if err != nil {
return fmt.Errorf("failed to execute CreateItem: %w", err)
}
defer res.Close()
// ... scan result into &result
return res.Err()
})
return result, err
}
Adding a column requires updates across all operations: INSERT, UPDATE, SELECT. Unlike JPA, where entities update automatically.
- Advantages of manual SQL: Full control over queries, no N+1 issues.
- Disadvantages: Verbosity, manual type mapping, enums.
- Comparison with JPA: JPA is simpler for basic schemas but hides execution details.
Transactions in Distributed YDB
YDB doesn’t support foreign keys but offers transactions. Without @Transactional, management must be manual.
TransactionManager checks the context for an existing transaction:
func (t *TransactionManager) DoTx(ctx context.Context, fn func(ctx context.Context, tx table.TransactionActor) error) error {
if tx, ok := TxFromContext(ctx); ok {
return fn(ctx, *tx)
}
return t.ydb.NativeDriver.Table().DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
ctx = context.WithValue(ctx, txKey{}, &tx)
return fn(ctx, tx)
})
}
This emulates Spring’s propagation: either start a new transaction or reuse an existing one. Eliminates code duplication (Create vs CreateTx).
Services and repositories inject the manager. Multiple operations in a single transaction work seamlessly.
Manual Dependency Injection and Build
Without Spring IoC, dependencies are assembled in main.go in order: DB → repositories → services → handlers. No reflection, proxies, or runtime loops.
Advantages:
- Explicit dependency graph.
- No magic or unexpected errors.
- Easy refactoring.
Disadvantages: manual boilerplate for every component.
Error Handling with Context
Go errors are values without stack traces. Solution: unique prefixes in fmt.Errorf.
func (s *ItemService) ProcessItem(ctx context.Context, id string) error {
item, err := s.repo.FindById(ctx, id)
if err != nil {
return fmt.Errorf("ProcessItem: failed to find item %s: %w", id, err)
}
// ...
}
In logs: ProcessItem: failed to find item abc123: ItemRepository.FindById: query failed: connection refused. The chain points precisely to the failure point.
Optimizing YDB Request Units
Initial schema mirrored PostgreSQL: letter statistics in separate rows, INSERT/UPDATE loops per key. This burned through RU fast.
Optimizations:
- Aggregate data client-side before insertion.
- Batch operations.
- Reevaluate indexes and partitioning.
After fixes, we stay within the free tier.
Key Takeaways
- Go delivers cold starts under 1 second for Serverless Containers vs seconds with Spring Boot.
- YDB demands manual YQL and DECLARE parameters; simplify schemas to save RU.
- TransactionManager emulates @Transactional without code duplication.
- Manual DI is clearer than Spring but requires discipline in main.go.
- Wrap errors with prefixes for readable logs.
— Editorial Team
No comments yet.