# hexlet-basics: a Go rewrite of an interactive programming course platform

> hexlet-basics is the codebase behind code-basics.com, the interactive programming course site, and it is midway through a rewrite from Rails to a Go API with a TanStack Start frontend and a TypeSpec defined HTTP contract. The interesting part is the contract first pipeline, and the interesting risk is that two stacks now live in one repository.

**hexlet-basics/hexlet-basics** — Interactive Programming Courses

- Repository: https://github.com/hexlet-basics/hexlet-basics
- Website: https://code-basics.com
- Stars: 547 · Forks: 100
- Language: TypeScript
- License: AGPL-3.0
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/hexlet-basics-hexlet-basics

## Two applications share one repository right now

The first line of the readme states the situation plainly: this repository is mid-migration from Rails to Go. The root is the new stack, a Go API whose HTTP contract is generated from TypeSpec, plus a TanStack Start frontend with React 19 and TypeScript. The original Rails app lives in legacy/ with its own README and its own Makefile.

That is a significant fact for anyone assessing the project, because it means the repository is not a single system you can read end to end. The directory listing confirms the scale of it. There is a cmd/ directory, an internal/ directory, an ent/ directory for the ORM, an api-spec/ directory for the contract, a src/ directory for the frontend, plus migrations/, fixtures/, e2e/, k8s/, scripts/, config/, docs/, a legacy/ tree and a mise/ directory of task definitions.

There are also two Dockerfiles, a normal one and a Dockerfile.web, and separate .air.toml and .air.worker.toml files for hot reloading the API and the worker independently. The presence of an AGENTS.md file, a .agents/ directory, a GLOSSARY.md and a .mcp.json file suggests the project is set up for AI assisted development as well as for people.

For a reader, the useful question is which parts to trust. The answer from the documentation is the new root, with the legacy tree kept around as the reference implementation while behaviour is ported across. Nothing in the visible documentation dates the end of the migration, so the boundary is not a fixed point you can plan around.

The topic tags are learn-to-code and hacktoberfest, and the project is described as interactive programming courses, which is the domain context: a platform where learners read an exercise, write code in a browser editor and get feedback.

## The HTTP contract is generated, and hand edits are forbidden

The architectural centre of the project is a contract first pipeline, and the documentation is unusually firm about the rule. Edit the TypeSpec in api-spec/*.tsp, then regenerate, and never hand-edit generated files. The decision is recorded as ADR-0001.

The pipeline has four stages that the readme names directly.

```bash
mise run gen       # TypeSpec -> OpenAPI -> ogen Go server + hey-api TS client
mise run gen:ent   # regenerate the ent ORM from ent/schema
mise run gen:all   # gen-ent + gen
mise run dev:spec  # watch TypeSpec and re-emit OpenAPI on change
```

The chain starts with TypeSpec as the source of truth, compiles to OpenAPI at api-spec/dist/openapi.yaml, and then fans out in two directions. ogen generates the Go server from that spec, and hey-api generates the TypeScript client for the frontend. A second generator, ent, produces the ORM layer from the schema in ent/schema, and a separate task regenerates the database migrations from that same schema.

The go.mod confirms the toolchain. The module is hexletbasics on Go 1.27.2, and the require block lists github.com/ogen-go/ogen at v1.24.0 for the server generation, entgo.io/ent at v0.14.6 for the ORM, ariga.io/atlas at v1.3.0 for migrations, and pgx v5 for the database driver. The TypeScript side of the generation appears in package.json through openapi-ts and the tsp compile scripts, with the OpenAPI output fed to both the Go and the TypeScript generators.

The practical benefit is that a schema change is a single edit that propagates to the server, the client and the ORM. The practical cost is that a change requires the generators to be run, that a developer must have the toolchain available, and that the diffs in the repository include a lot of generated code.

## One setup command, and a toolchain pinned by mise

The onboarding story is short. After cloning, you run one task, and it installs the toolchain and git hooks, creates the .env file, then runs the separate tasks install, db:fixtures and dev, so the project is up when it finishes.

```bash
   mise trust       # once: let mise read this repo's mise.toml
   mise run setup   # or just: mise setup
```

mise trust comes first because mise will not read a configuration file from a repository it does not trust, and the configuration here pins the entire toolchain in mise.toml, including go, node, pnpm, golangci-lint, atlas and hk. The Go codegen and dev tools such as air, ent and ogen come from tool directives in go.mod, so the readme says nothing needs to be installed by hand.

That reproducibility is the most valuable thing about the setup, and it comes at a cost. A developer needs mise and Docker available, and the readme lists docker as a requirement because Go tests start disposable PostgreSQL containers. It is also worth noting that the Go test path does not need Docker: the readme states that the local PostgreSQL 18 daemon is used instead, with each database dependent Go package getting its own database cloned with pgtestdb from a template that holds the atlas migrations and the fixtures.

The day to day tasks are separable, which is what you actually use after the first day.

```bash
mise run lint          # gofmt + go vet + golangci-lint, and tsc
mise run lint:fix      # auto-fix Go formatting and lint findings

mise run test          # go test ./... on a database cloned per package
```

A migration can be scaffolded with mise run db:migrate-new and a name, the schema can be created and migrated without touching data with db:prepare, and the fixtures are loaded with db:fixtures. The readme is explicit that without the fixtures the site shows nothing, and that re-running setup reloads them.

## Ports, the development database and where the environment comes from

The port choices are deliberate, and reading them tells you what to expect when you start the stack.

PostgreSQL 18 is published on port 54330 so it does not conflict with a local database on 5432, and the service is for development only. Under mise, DATABASE_URL points at that local development database, and the API and Vite ports are dynamic, with the API from 3002 and Vite from 5173, taking the next free port when one is taken. You can print the actual values with mise env | grep PORT.

The .env.example file is the reference for configuration, and it carries a warning at the top: real environment variables win over anything set in .env, and production secrets must be kept out of the file because it is committed.

The values in it show the shape of the configuration. APP_ENV selects development, production or test, and .env is only read when it is unset or set to development. The API listens on ADDR with a separate HEALTH_ADDR for the health endpoint. PUBLIC_URL, APP_HOST and SITE_URL are described as required with no code default, and SITE_URL is the base for account email links, with the account email written to the log unless a Postbox key is set. BLOB_BUCKET_URL points at a local file path with create_dir=true in development, which tells you the storage layer is pluggable and defaults to local disk.

Two features are off by default and worth noticing. The MCP endpoint at /mcp is enabled with MCP_ENABLED and accepts only OAuth tokens, and the OAuth authorization server for MCP clients is enabled with OAUTH_ENABLED, with an issuer that must match the origin the browser reaches the SSR server on and token lifetimes that default to one hour for access and thirty days for refresh. The go.mod also lists a Model Context Protocol Go SDK, so the MCP endpoint is a real feature rather than a placeholder.

## One binary per process, and a distroless runtime

The Dockerfile is worth reading closely because it encodes a deployment decision.

The comment at the top states the rule: one binary per process, as recorded in ADR-0010, plus the atlas CLI for the pre-upgrade migration job, so a release ships exactly one Go artifact. The chart picks the process with a command setting, choosing between /server, /worker or atlas.

```dockerfile
FROM golang:1.27.2 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
```

The build stage is a plain Go image, and the two binaries are compiled with CGO_ENABLED=0 and -trimpath, with -s and -w passed to the linker. The comment explains why: every dependency is pure Go, including go-git for course clones and the Docker SDK for the runner, so the runtime image needs no libc, and -trimpath keeps build paths out of the binary, which is what makes rebuilds of the same commit byte-identical.

The runtime is gcr.io/distroless/static-debian13:nonroot, with CA certificates and /tmp, running as uid 65532. The migrations directory is copied in so the atlas binary can run migrations against a database at deploy time, and the port exposed is 3001. The entrypoint is empty and the default command is /server, so the same image runs the API, the worker or the migration job depending on what the chart asks for.

One detail in the comments is a licensing decision in disguise. The atlas stage is the community build from arigaio/atlas at 1.3.0-community rather than the default MSA-licensed image, and the comment ties that to ADR-0014. It is a small thing, but it shows the kind of attention the project pays to what ends up in a shipped artefact.

## The Rails app is the specification for what still has to be ported

The migration is the defining fact of the repository, and the legacy/ directory is how you understand the scope of the work.

The readme says the original Rails app lives in legacy/ with its own README and Makefile, and the fixture loading task explicitly says it replaces what the fixture tables hold, like rails db:fixtures:load did. That parallel is the clue. The Go services are being written to reproduce behaviour that the Rails app already had, and the Rails app is the reference for what that behaviour is.

The go.mod makes the porting visible in the dependency list. There is github.com/riverqueue/river and its SQL driver, which is a Go job queue for the asynchronous worker, and github.com/ThreeDotsLabs/watermill with a SQL transport, which is a messaging abstraction. There is go-git for cloning course repositories, a Docker SDK client and API, the AWS SDK for S3 and SESv2, Sentry for error reporting, and the OpenAI Go SDK alongside the AI SDK React package in package.json, which points to AI features in the product. A Model Context Protocol Go SDK and an MCP server route are there too, and the k8s/ directory and helm-style chart references point to a Kubernetes deployment story.

The frontend is a substantial application rather than a thin skin. The package.json dependencies include Mantine for the component layer with a code highlight package, Monaco for the in-browser code editor, TanStack Router, React Query, React Form, TipTap with a link extension and starter kit for rich text editing, react-markdown with remark and rehype plugins including directive support, i18next with react-i18next and an i18next.config.ts at the root, and a dayjs date library. The i18n setup and the go-i18n tool directive in go.mod say the platform is multilingual, with translation handling on both sides.

Taken together, this is a course platform with a browser code editor, background job processing, email, object storage, an AI integration and an MCP surface, being rebuilt on a new stack while the old one runs alongside it.

## Where a mid-migration repository is the wrong starting point

The first limitation is the one the readme states outright. The repository is mid-migration, so a lot of the behaviour you can see in the product may still be implemented in the Rails app, and the split between the two is not documented as a checklist. If you want to understand how a feature works, the first question is which tree owns it.

The second limitation is the toolchain weight. To run this locally you need mise, Docker, a pinned Go toolchain, node and pnpm, plus the code generators. The payoff is reproducibility, since the versions are pinned rather than whatever is on your machine, but the cost is that a contributor needs a specific setup before they can read anything.

The third is the generated code surface. The contract first pipeline means a large part of the repository is output rather than authorship. Editing generated files is forbidden, so changes must go through the TypeSpec, which is correct in principle and slow in practice when you are trying to fix a small behavioural difference.

The fourth is the licence, and for a course platform it is a real consideration. The project is licensed under the GNU General Public License v3.0, and the LICENSE file is the authority on the details. If you run this code as a service and modify it, the AGPL obligations around conveying the corresponding source to users are the kind of thing that needs proper advice rather than a summary in a review.

The alternative to weigh against it is not a competing repository but a different architectural choice. A Rails or Django application with a template layer and a background job system solves the same problem with far less machinery, at the cost of a runtime rather than a compiled binary and the absence of a typed contract between front end and back end. The Go and TypeSpec approach buys static types across the API boundary, a distroless runtime and one binary per process, and it pays for that with a generated pipeline and a migration to maintain.

On maintenance, the facts are worth stating without embellishment. The last push was on 2026-10-22, and the release tags are unusual, with names like hexlet_basics-v0.1.4-main2.2, which reflects a release that tracks the main branch migration rather than a stable version line. Commits follow Conventional Commits enforced by commitlint, release-please maintains a release pull request that bumps package.json and CHANGELOG.md, and merging it tags vX.Y.Z. The version in package.json is 0.0.0 and the package is marked private, so there is no npm distribution to depend on, and the site itself is at code-basics.com.

## Conclusion

hexlet-basics suits a developer who wants to study a contract first full stack, with a generated Go server, a typed frontend client and a reproducible toolchain pinned by mise, and it does not suit someone looking for a small course engine they can drop into an existing Rails or Django product. Before you build on it, read the ADR directory and the docs/STACK.md file, because the repository is mid-migration and the legacy Rails app still holds behaviour that the Go services do not yet reproduce, and note that the licence is AGPL-3.0, which matters if you plan to run a modified copy as a service.

## FAQ

### What is hexlet-basics and what stack does it use?

It is the codebase behind code-basics.com, the interactive programming course site. The root is the new stack, a Go API with a TypeSpec generated HTTP contract plus a TanStack Start frontend on React 19 and TypeScript, while the original Rails app lives in the legacy/ directory.

### How do I set up the hexlet-basics project locally?

Run mise trust once so mise can read the repository's mise.toml, then run mise run setup, which installs the pinned toolchain and git hooks, creates .env, and runs the install, db:fixtures and dev tasks so the project comes up when it finishes.

### How is the API contract managed in hexlet-basics?

TypeSpec files in api-spec are the source of truth and are compiled to OpenAPI, then ogen generates the Go server and hey-api generates the TypeScript client. The readme says to edit the TypeSpec and regenerate, and never to hand-edit generated files.

### Does running hexlet-basics require Docker?

Docker is listed as a requirement because Go tests start disposable PostgreSQL containers, but the readme also states that mise run test uses the local PostgreSQL 18 daemon and clones a database per package with pgtestdb, with no Docker needed.

### What licence is hexlet-basics under?

The project is licensed under the GNU General Public License v3.0, and the LICENSE file in the repository root holds the details. That matters if you run a modified copy as a service, so check the file and take advice rather than relying on a summary.

## Sources

- [hexlet-basics/hexlet-basics on GitHub](https://github.com/hexlet-basics/hexlet-basics)
- [License: AGPL-3.0](https://github.com/hexlet-basics/hexlet-basics/blob/main/LICENSE)
- [Project website](https://code-basics.com)
- [README](https://github.com/hexlet-basics/hexlet-basics/blob/main/README.md)
- [Releases](https://github.com/hexlet-basics/hexlet-basics/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/hexlet-basics-hexlet-basics
