I am a software developer based in Tallinn, Estonia. While learning the Estonian language I figured there is no publicly available API for the language dictionary. The Estonian Language Institute provides a language portal called Sonaveeb but no way to programmatically access the dictionary content.
SONAPI is an API built on top of the EKI LEX dictionary and my side project to get a deeper understanding of NodeJS, Typescript, and architectural concepts. This project is a demonstration of Hexagonal Architecture (also called Ports & Adapters).
Live example: https://api.sonapi.ee/v2/tubli
git clone https://github.com/BenediktGeiger/sonad-api.git
cd sonad-api
cp .env.docker .env
docker-compose up --build
| URL | Information |
|---|---|
| http://localhost:8083/v2/{word} | API Endpoint |
| http://localhost:9090/ | Prometheus instance to collect NodeJS and API metrics. |
| http://localhost:3000/ | Grafana Dashboard |
git clone https://github.com/BenediktGeiger/sonad-api.git
cd sonad-api
cp .env.local .env
npm install
npm run dev
| URL | Information |
|---|---|
| http://localhost:8083/v2/{word} | API Endpoint |
The core idea: dependencies always point inward. The application core defines interfaces (ports) for everything it needs. The outside world implements them (adapters). Nothing in the core knows about Express, Redis, Ekilex, or any other infrastructure detail.
┌──────────────────────────────────┐
│ PRIMARY ADAPTERS │
│ (they drive the application) │
│ │
│ REST Controller │
│ CLI │
└─────────────────┬────────────────┘
│ drives
┌───────────────────────▼──────────────────────────┐
│ APPLICATION CORE │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ PORTS │ │
│ │ (interfaces defined by the core) │ │
│ │ │ │
│ │ ExternalDictionaryV2Port │ │
│ │ DictionaryCachePort │ │
│ │ LoggerPort │ │
│ │ TranslatorPort │ │
│ │ AsciiPort │ │
│ └───────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ SERVICES & QUERIES │ │
│ │ │ │
│ │ DictionaryV2Service │ │
│ │ TranslatorService │ │
│ │ GetDictionaryEntryQueryHandler │ │
│ └───────────────────────────────────────────┘ │
└───────────────────────┬──────────────────────────┘
│ implemented by
┌─────────────────▼────────────────┐
│ SECONDARY ADAPTERS │
│ (driven by the application) │
│ │
│ Ekilex Adapter │
│ Redis Cache Adapter │
│ Winston Logger Adapter │
│ Postgres Translator Adapter │
│ Ascii Service Adapter │
└──────────────────────────────────┘
The composition root (config/service-locator.ts) is the only place that knows about
both sides — it wires ports to adapters at startup and owns all assembly knowledge.
The folder structure mirrors the architecture directly:
lib/
│
├── config/
│ └── service-locator.ts ← Composition root. Wires ports to adapters.
│ The only file that imports from both
│ application/ and infrastructure/.
│
├── primary-adapters/ ← PRIMARY ADAPTERS (left side of the hexagon)
│ ├── http/
│ │ ├── controllers/ ← Drives the app via HTTP
│ │ ├── middlewares/
│ │ ├── routes/
│ │ ├── ports/ ← HTTP-specific port (rate limiter)
│ │ └── infrastructure/
│ │ └── rate-limiter/ ← Redis / in-memory rate limiter adapters
│ └── cli/ ← Drives the app via CLI
│
├── dictionary/ ← BOUNDED CONTEXT: Dictionary
│ │
│ ├── application/ ← APPLICATION CORE (centre of the hexagon)
│ │ ├── ports/ ← Port interfaces (the boundary)
│ │ │ ├── external-dictionary-v2.interface.ts
│ │ │ ├── dictionary-cache.interface.ts
│ │ │ ├── logger.interface.ts
│ │ │ ├── translator.interface.ts
│ │ │ ├── request-logger.interface.ts
│ │ │ └── ascii.port.ts
│ │ ├── queries/ ← CQRS query handlers
│ │ ├── dictionary-v2-service.ts
│ │ └── translator-service.ts
│ │
│ └── infrastructure/ ← SECONDARY ADAPTERS (right side of the hexagon)
│ ├── ekilex/ ← Implements ExternalDictionaryV2Port
│ │ ├── dictonary-ekilex.ts
│ │ └── inMemory/ ← In-memory stub for local dev
│ ├── cache/ ← Implements DictionaryCachePort
│ │ ├── redis-cache/
│ │ └── no-cache/
│ ├── logger/ ← Implements LoggerPort
│ │ ├── winstonLogger/
│ │ ├── winstonAxiomLogger/
│ │ └── consoleLogger/
│ ├── translator/ ← Implements TranslatorPort
│ │ ├── postgres/
│ │ ├── sqlite/
│ │ └── inMemory/
│ ├── request-logger/ ← Implements RequestLoggerPort
│ │ ├── postgres/
│ │ └── console/
│ ├── ascii/ ← Implements AsciiPort
│ └── bus/ ← RoutingBus / LoggerBus / RetryBus
│
└── shared/ ← Truly shared, no infrastructure dependencies
├── bus/ ← Bus interfaces (Command, Query, Handler)
├── common/ ← Utilities (Either, Result, StopWatch)
└── domain/ ← Base domain classes (Entity, ValueObject)
Each port in application/ports/ has one or more adapters in infrastructure/.
Swapping an adapter (e.g. Redis → in-memory cache) requires zero changes to the core.