JavaRush /Courses /Go SELF /What to learn next: SQL/DB, Docker, CI, profiling, securi...

What to learn next: SQL/DB, Docker, CI, profiling, security

Go SELF
Level 72, Lesson 2
Available

1. What to learn next: engineering areas around Go

When a course ends, a typical thought appears: “Alright, time to learn the next if!” (spoiler: there is no next if). In practice, growth as a Go developer almost always comes not from one more language construct, but from expanding context: where data lives, how the application is delivered, how it is checked automatically, how performance is measured, and how it is protected from attacks and operational mistakes.

A useful mental model is this: Go is the “engine”, and the rest of engineering is the “road, traffic rules, and inspections”. You can have a fantastic engine, but if you hit the highway without brakes, headlights, and insurance, the trip will be short—but full of emotions.

As an example, we will continue developing our training project Tasker: we have a domain model for tasks, a CLI and/or HTTP API, and a storage layer (initially file/memory) that we want to make “properly grown-up”.

Below are five directions that give the best return after basic Go: SQL/DB, Docker, CI, profiling, security.

How these directions fit into one picture

So your brain does not perceive SQL/Docker/CI/pprof/security as five disconnected universes, it helps to see them as one application life cycle. That same Tasker goes through this path in almost any real project:

flowchart TD
    A[Go code: domain + app + adapters] --> B[Storage: SQL/DB]
    A --> C[Docker: reproducible run]
    A --> D[CI: automated checks]
    A --> E[Profiling: measure and explain]
    A --> F[Security: limits, secrets, error contracts]
    D --> C
    C -->|deploy/run| G[Prod environment]
    G --> E
    G --> F
    B --> G

The meaning of the diagram is simple: each area strengthens the others. CI protects the Docker build from regressions, Docker makes runs reproducible, the DB provides stable data, profiling helps you stop guessing, and security prevents your success from turning into a public story on Habr.

2. SQL and databases: how to think about data

SQL and databases often scare beginners not because of syntax, but because it feels like “that’s a whole universe”. Good news: to become productive, you do not need to memorize every kind of JOIN. You need to learn two things: first, model data (how it lives over time, what the key is, which fields are required), and second, keep a boundary between the domain and storage so the DB does not leak into every layer of the application.

Data model: a task is not only a title

When you move Tasker from a file into a DB, a task gets an “official biography”. For example, you suddenly care when a task is created, when it is completed, whether done_at can be NULL, and what to do about deletion (delete physically or mark as deleted).

This is where the discipline from the invariants topic helps a lot: the domain model must be honest. If a task has an ID, do not pretend it does not. If Title is required, validate it at the boundary (CLI/HTTP), not “let it fail in the DB”.

A mini-skeleton of a domain type (without diving into migrations and ORM) can be:

package domain

import "time"

type Task struct {
	ID        int
	Title     string
	Done      bool
	CreatedAt time.Time
	DoneAt    *time.Time
}

Notice *time.Time: this is an honest way to say “may be absent”.

Layer boundary: the domain must not know about database/sql

The most common architectural skew for a beginner: “since I connected a DB, let the domain logic return sql.ErrNoRows.” It seems convenient, but it actually makes a specific driver/package part of your public contract. The Go community explicitly emphasizes that wrapping and “passing through” low-level errors outward can break abstractions: if you let calling code do errors.Is(err, sql.ErrNoRows), you are implicitly promising that your storage implementation will always be based on database/sql and will return exactly that reason.

In Tasker, it is better to keep “your own” domain errors:

package domain

import "errors"

var ErrTaskNotFound = errors.New("task not found")

And the DB adapter itself decides how to map sql.ErrNoRows to domain.ErrTaskNotFound.

Practical step: a storage interface and an SQL adapter

If you already have a Storage interface, then the SQL version is just one more implementation. The important part is not turning it into a “god object”, but keeping small methods.

package app

import "context"

type Storage interface {
	Create(ctx context.Context, title string) (int, error)
	Get(ctx context.Context, id int) (Task, error)
	List(ctx context.Context) ([]Task, error)
	MarkDone(ctx context.Context, id int) error
}

And this is where SQL fits very naturally: each method is 1–2 queries.

Mini example with database/sql: connection and timeout

Beginners often have two extremes: either not setting a timeout at all (“maybe it will be fine”), or setting 1 millisecond (“so it will be fast”). It is more practical to start with a careful context.WithTimeout at the boundary of the storage operation.

package sqlstore

import (
	"context"
	"database/sql"
	"time"
)

func GetTask(ctx context.Context, db *sql.DB, id int) (Task, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()
	// next will be QueryRowContext + Scan
	return Task{}, nil
}

The point is not the magic number 2, but the habit: external resources must have a time limit, otherwise your application will sooner or later hang “forever”.

What to learn in SQL/DB after the course

So you do not drown, keep focus on the practical minimum for applied Go:

  • Tables and keys (primary key, unique).
  • Indexes (why they exist and how they affect lookup).
  • Transactions (what they give you, at least at the “all or nothing” level).
  • Migrations as the discipline of changing a schema.
  • Basic diagnostics: ability to read a slow query and understand “why” (index not used, filter on a non-indexable field, etc.).

This is enough for Tasker on a DB to be real, not just educational.

3. Docker: reproducible packaging and execution

Docker is often sold as a “magic box”, but it is more useful to think of it as a standardized way to describe an environment so your Tasker runs the same on your machine, your coworker’s, and on the server. This is not about “trendy”; it is about “reproducible”: the same Go version, the same dependencies, the same build commands, the same environment variables.

There is a gentle irony: many start learning Docker to “not deal with the environment”. And end up understanding environments better than anyone—simply because Docker forces you to describe them explicitly.

Minimal goal: a static binary in a container

Tasker is an ideal candidate: one binary, config via env, no Node.js zoo.

The simplest Dockerfile (in “two stages”, so the final image is small) can look like this:

FROM golang:1.25 AS build
WORKDIR /src
COPY . .
RUN go test ./... && go build -o /bin/tasker ./cmd/tasker

FROM gcr.io/distroless/base-debian12
COPY --from=build /bin/tasker /tasker
ENTRYPOINT ["/tasker"]

There is an important habit here: tests as part of the build. Not because “that’s how it’s done”, but because otherwise one day you will deploy a broken binary and assure coworkers that “it worked on my machine”.

Configuration via env: one binary, different environments

Docker shines especially well when config is not hard-coded. You already did env config in the course, so we will just reinforce the minimal pattern.

package config

import "os"

type Config struct {
	Addr string
	DSN  string
}

func Load() Config {
	return Config{Addr: env("TASKER_ADDR", ":8080"), DSN: env("TASKER_DSN", "")}
}

func env(key, def string) string {
	if v, ok := os.LookupEnv(key); ok && v != "" { return v }
	return def
}

If DSN is empty, that can be a fail-fast startup error, because without it the SQL storage will not come up. And that is good: “breaking immediately” is usually cheaper than “breaking on the third request”.

What to learn next in Docker

After the first successful run, more mature questions usually pop up:

  • how to run a DB alongside it (docker compose);
  • how to publish ports;
  • how to persist data (volumes);
  • how to set a healthcheck;
  • how to build multi-arch images;
  • how not to run the process as root.

But that is already the “second lap”. On the first lap, it is enough to learn how to package and run.

4. CI: making quality repeatable

CI (Continuous Integration) can look like bureaucracy to beginners: “why should a robot run what I can run locally anyway?” In practice, CI is your way to make quality repeatable. Locally, you might forget to run tests or accidentally miss a warning. CI does not forget. It breaks too, of course—but at least it breaks the same way for everyone.

The course approach of a “quality gate” turns into a team habit here: formatting, tests, vet, linters—in one pipeline. And you stop arguing “do we need to run gofmt”, because there is no one to argue with: the robot silently does not accept the PR.

Minimal GitHub Actions workflow for Go

Below is a compact example. It is not perfect “for all cases”, but as a starter template it is good: format, tests, vet.

name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: "1.25.x" }
      - run: gofmt -w .
      - run: go test ./...
      - run: go vet ./...

Yes, gofmt -w . in CI is a debatable thing (usually formatting is checked, not applied). But for a training project it clearly demonstrates the idea: code style is part of the language.

CI contract for Tasker: what exactly should be green

The essence of CI is not the number of steps, but that it reflects your Definition of Done. For Tasker, a reasonable minimum is: the project builds, tests pass, the linter does not complain about critical issues, the CLI/HTTP contract does not fall apart. That is already “quality communication”: you are not just saying “everything works”, you are showing how you prove it.

5. Profiling: optimize based on facts

Optimizing “by eye” is like treating a disease from a photo: sometimes you guess right, but you really should not. Profiling is a discipline that forces you to measure first, then change, then measure again. In Go, this world is very friendly: you have benchmarks, pprof, tracing. The key is not to treat it like a “magic shaman tambourine”, but as a normal observability tool.

A very important psychological win for a beginner is to understand that slow does not mean “badly written”—it means “there is a bottleneck”. Bottlenecks happen even in good code, because the real world is tricky.

Benchmarks: measure a specific function

If in Tasker you have, for example, filtering tasks by substring, you can measure it on data. Even if you never optimize it later, you at least train the “measure” muscle.

package tasker

import "testing"

func BenchmarkContains(b *testing.B) {
	tasks := makeTestTasks(10_000)
	for i := 0; i < b.N; i++ {
		_ = filterByTitle(tasks, "read")
	}
}

What matters is that you are measuring one piece, not “the whole application end-to-end”.

pprof in a service: where time and memory go

When Tasker becomes an HTTP service, you want to see: where CPU time is spent, where allocations happen, where locks block. In Go, this is usually done by enabling net/http/pprof on a separate address/port (or behind protection). Within “what to learn next”, it is enough to know that it is possible—and that you must block it from the outside world.

Tracing: why a request is sometimes slow

Sometimes the problem is not average time, but “it occasionally hangs”. Tracing tools help here. Go has a built-in ecosystem for trace analysis, and the documentation emphasizes that tracing helps diagnose timeouts and “rare” delays if you have a way to capture the right interval.

Even if you do not immediately implement complex setups, it is important to internalize a practical idea: profiling is not only about “making it faster”, it is also about “explaining behavior”.

6. Security: the basic minimum for applied development

Security can sound like a topic for intelligence agencies to a beginner, but in applied development it starts with simple things: input limits, correct error messages, no secret leakage, file path protection, timeouts, the principle of least privilege. These are not “extra features”; they are part of quality, because a breach and data loss are also bugs—just with a particularly unpleasant incident report.

It helps to remember: most incidents happen not because of Hollywood hackers, but because of “unexpected input” and “we forgot to close an external-facing debug endpoint”.

Validation and limits: defend against a big request

If you have an HTTP API, you already know the idea of MaxBytesReader and strict decoding. Even in a CLI there is a similar idea: do not trust input. For example, a task ID must be positive, not “-9999999999”.

package app

import (
	"errors"
	"strconv"
)

func parseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil || id <= 0 { return 0, errors.New("id must be positive int") }
	return id, nil
}

This is not “perfect security”, but it is an honest boundary: bad input must not slip inside.

Secrets: do not log them and do not store them in the repository

Secrets are API keys, passwords, tokens. The basic rule is boring but life-saving: secrets come via env/secret stores, and you never print them in logs. Even in debug. Especially in debug.

package config

import (
	"errors"
	"os"
)

func MustAPIKey() (string, error) {
	key, ok := os.LookupEnv("TASKER_API_KEY")
	if !ok || key == "" { return "", errors.New("TASKER_API_KEY is not set") }
	return key, nil
}

The error message should help configure the system, but not reveal the key itself.

Errors: do not expose internals externally

You already did this in the course in both CLI and HTTP: internal details go to logs; externally you return a stable message/contract. This is not only about UX, but also about security: an error text can contain a file path, DSN, internal structures, and all of that is unnecessary for the client.

And again it is useful to remember the idea about error abstractions: if you pass low-level details through to the outside, you accidentally promise the client “too much”, and later it becomes hard to change.

What to learn next in security

After the course, a good security “roadmap” looks like this:

  • OWASP Top 10 at the level of understanding the terms.
  • Secure handling of configuration and secrets.
  • Authorization basics (API key/JWT as concepts).
  • Safe work with files and paths.
  • Basic logging practices (do not write PII, request_id, minimal noise).

This already gives you the level of “I do not shoot myself in the foot every other day”.

7. Typical mistakes after the course

Mistake #1: “First I’ll learn Docker, then databases, then tests”—and nothing gets done.
This happens because the directions seem big, and your brain tries to pick the “most correct” one. It is more practical to go from the project’s needs: you hit storage limits—add SQL; you are tired of “it works on my machine”—add CI; you want a consistent run—add Docker.

Mistake #2: Dragging database/sql and sql.ErrNoRows into the domain layer.
It looks convenient in the moment, but you pay with architecture: the domain starts to depend on storage details. In the Go approach, it is often emphasized that wrapping and passing through a low-level error makes it part of the API, which complicates swapping implementations.

Mistake #3: Thinking profiling is needed “when it gets slow”.
In practice, you need it when you want to stop arguing and start knowing. And “slow” might not be about CPU—it can be locks, I/O waits, GC. Tracing and profiles are not a luxury; they are a way to explain what is happening, especially in rare hangs.

Mistake #4: Storing secrets in the repository “temporarily, for a test”.
Temporary secrets usually live the longest, because “I’ll remove it later” is the most reliable lie in programming. The habit must be the opposite: secrets only via env/secret store, and the application crashes on startup if a secret is not set.

Mistake #5: Making CI that is “always green” because it checks nothing.
This happens when you want to quickly “check the box”. Real CI should verify what you want to trust: tests, vet, minimal linters, build. Otherwise it turns into decorative lights: it shines nicely, but it does not warm you up.

1
Task
Go SELF, level 72, lesson 2
Locked
Secure configuration
Secure configuration
1
Task
Go SELF, level 72, lesson 2
Locked
Stable error
Stable error
1
Task
Go SELF, level 72, lesson 2
Locked
Task Filter
Task Filter
1
Task
Go SELF, level 72, lesson 2
Locked
Building the project
Building the project
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION