Back to Home

Go and YDB Serverless instead of Spring Boot

Developer migrated application from Spring Boot and PostgreSQL to Go and YDB Serverless in Yandex Cloud. Described challenges of manual SQL, transactions, DI and RU optimization for free tiers.

Go + YDB: fitting into the free tier of Yandex Cloud
Advertisement 728x90

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.

Google AdInline article slot

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:

Google AdInline article slot
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:

Google AdInline article slot
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

Advertisement 728x90

Read Next