Key design choices and trade-offs.
TaskResolver is a stateless request/response service: each POST /resolve
carries a full job payload, the server validates and sorts it in-process, and
returns the result. Nothing is persisted.
| Area | Decision | Rationale |
|---|---|---|
| Web stack | Plug + Bandit |
Lightweight; no Phoenix for a single endpoint |
| Endpoint | Stateless RPC (POST /resolve) |
Input → output transform; REST adds no value without persistence |
| Graph | Kahn's sort on Map/MapSet/List |
Pure, heap-local; no :digraph or graph libs |
| Validation | Ecto.Changeset + embedded_schema |
Structured validation without a database |
| Output | Accept header negotiation |
Same result as JSON or bash script |
| Errors | 400/406/415/422 taxonomy |
HTTP concerns separated from business-logic failures |
| Delivery | Docker via docker compose up |
One command for app + API docs (see below) |
TaskSchema+JobSchema(embeds_many) model the payload viaembedded_schema— noEcto.Repo.JobSchemapre-checks the rawtasksparam (missing, not a list, non-objects) beforecast_embed/3, so bad input becomes a changeset error, not a crash.cast_embed(:tasks, required: true)also rejects empty task lists.- Duplicate names are checked cross-task after casting via
get_field(:tasks). :requiresis cast withempty_values: []so blank dependency names are rejected explicitly (Ecto otherwise silently drops them from arrays).- Errors are translated to a JSON-encodable map via
traverse_errors/2. - Public API returns plain maps:
{:ok, [task_map]}or{:error, {:invalid_job, errors_map}}.
- Kahn's algorithm on
Map/MapSet/Listonly — no:digraph. - Adjacency preserves input order so ties resolve deterministically (matches the
challenge example:
task-1, task-3, task-2, task-4). - Errors, checked in order:
{:duplicate_task, name}→{:missing_dependency, name}→{:cyclic_dependency, names}. - Cycle reporting uses a bounded DFS on the stuck subgraph to return only tasks actually on the loop, not those merely blocked by it.
| Source | Shape | HTTP |
|---|---|---|
JobValidator |
{:error, {:invalid_job, errors_map}} |
422 |
Sorter |
{:error, {:duplicate_task, name}} |
422 |
Sorter |
{:error, {:missing_dependency, name}} |
422 |
Sorter |
{:error, {:cyclic_dependency, [names]}} |
422 |
400/406/415 are HTTP-layer only (malformed JSON, bad headers).
POST /resolve— mirrorsTaskResolver.resolve_tasks/1; spec allows flexible routing.GET /health— liveness check for Docker; no JSON/Accept negotiation on GET.
- No
Plug.ErrorHandler: it responds then re-raises, which breaks directPlug.Testcalls.Plug.Parsersis invoked manually insidetry/rescueinstead. 415for missingContent-Type:Plug.Parserssilently accepts requests with noContent-Type; arequire_json_content_typeplug runs first to close that gap.406negotiation: missingAcceptdefaults to JSON; when present, the first supported media range in the header wins (client list order). UnsupportedAccept→406.- Response shapes: JSON omits
requires; bash is#!/usr/bin/env bash+ commands (BashFormatter). All errors share{error, message, details}JSON;ErrorMappershapes core failures into422bodies.
There is one Docker workflow: docker compose up --build. The two files
serve different roles in that same workflow — they are not alternatives:
| File | Role |
|---|---|
Dockerfile |
Build recipe for the Elixir app image (elixir:1.20-alpine, mix run --no-halt, no release) |
docker-compose.yml |
Orchestrator — starts the app (built from the Dockerfile) and Swagger UI together |
docker-compose.yml defines two containers, not two setups:
app— our service, built fromDockerfile, port4000, healthcheck on/healthswagger-ui— pre-built docs UI on port8080, mounts./docs/openapi.yaml(convenience for reviewers; not part of the Elixir app)
.dockerignore keeps the build context small; the app binds {0, 0, 0, 0} so
port 4000 is reachable from the host.
README.md— how to run (native + Docker) andcurlexamplesPLAN.md— phase checklistDECISIONS.md— this file (the why)