This guide shows you how to create specialized servers (Music Server, PDF Server, etc.) using Elixir Server Core as a foundation.
Overview
Elixir Server Core provides:
- [✓] Job queue with background workers
- [✓] HTTP server with REST API
- [✓] Telemetry and observability hooks
- [✓] OTP supervision for fault tolerance
You can fork this to create domain-specific servers by:
- Using as a library (recommended for most cases)
- Forking the repository (if you need to modify core functionality)
Method 1: Using as a Library (Recommended)
Add servcore to your mix.exs and configure it. The framework starts automatically with your supervision tree — no need to wire GenServers manually.
Step 1: Create a New Project
mix new my_music_server --sup
cd my_music_server
Step 2: Add the Dependency
# mix.exs
defp deps do
[
{:servcore, "~> 0.1"}
# Optional: for SQLite persistence
# {:exqlite, "~> 0.29"}
]
endStep 3: Configure
# config/config.exs
import Config
config :servcore,
router: MyMusicServer.Router,
port: 5000,
worker: MyMusicServer.MusicWorker,
worker_pool_size: 4,
job_store: Core.JobStore.SQLite,
job_store_opts: [database: "priv/jobs.db"]That's it. mix run --no-halt starts the HTTP server, job queue, worker pool, and persistence — all supervised.
Step 4: Create Your Custom Router
You have two approaches for creating a custom router:
Approach A: Using Helper Macros
# lib/my_music_server/router.ex
defmodule MyMusicServer.Router do
use Plug.Router
require Logger
plug Plug.Logger, log: :info
plug :match
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
plug Plug.Telemetry, event_prefix: [:server, :http]
plug :dispatch
# Import base route helpers
import Core.HTTP.BaseRouter
# Add standard routes from core
add_root_route()
add_health_route()
add_job_routes()
# Add your custom routes
get "/songs" do
songs = MyMusicServer.Library.all_songs()
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(songs))
end
get "/songs/:id" do
case MyMusicServer.Library.get_song(id) do
{:ok, song} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(song))
{:error, :not_found} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(404, Jason.encode!(%{error: "Song not found"}))
end
end
post "/songs/:id/play" do
case MyMusicServer.Player.play(id) do
:ok ->
send_resp(conn, 200, "Playing song #{id}")
{:error, reason} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(400, Jason.encode!(%{error: reason}))
end
end
post "/playlists" do
case conn.body_params do
%{"name" => name, "song_ids" => ids} ->
{:ok, playlist} = MyMusicServer.PlaylistManager.create(name, ids)
conn
|> put_resp_content_type("application/json")
|> send_resp(201, Jason.encode!(playlist))
_ ->
conn
|> put_resp_content_type("application/json")
|> send_resp(400, Jason.encode!(%{error: "Invalid playlist data"}))
end
end
# Catch-all (must be last)
match _ do
send_resp(conn, 404, "Not Found")
end
endApproach B: Copy Routes Manually
Sometimes it's simpler to just copy the routes you need:
# lib/my_music_server/router.ex
defmodule MyMusicServer.Router do
use Plug.Router
require Logger
alias Core.Workers.JobQueue
plug Plug.Logger, log: :info
plug :match
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
plug Plug.Telemetry, event_prefix: [:server, :http]
plug :dispatch
# Copied from Core.HTTP.Router
get "/" do
send_resp(conn, 200, "Music Server is running")
end
get "/health" do
worker_alive = Process.whereis(JobQueue) != nil
status = if worker_alive, do: "OK", else: "DEGRADED"
send_resp(conn, 200, status)
end
# Your custom routes...
get "/songs" do
# ...
end
match _ do
send_resp(conn, 404, "Not Found")
end
endStep 5: Choose a Job Store (Optional)
By default jobs are kept in memory and lost on VM restart. To persist jobs across restarts, implement the Core.JobStore behaviour. The framework provides Core.JobStore.SQL helpers so you only write connection plumbing.
Using SQLite (built-in)
Add {:exqlite, "~> 0.29"} to your mix.exs, then configure:
# config/config.exs
config :my_music_server,
job_store: Core.JobStore.SQLite,
job_store_opts: [database: "priv/jobs.db"]Writing a Custom SQL Adapter (e.g. PostgreSQL)
# lib/my_music_server/job_store/postgres.ex
defmodule MyMusicServer.JobStore.Postgres do
@behaviour Core.JobStore
alias Core.JobStore.SQL
alias Core.Workers.Job
def init(_opts) do
{table, index} = SQL.schema()
Postgrex.query!(conn(), table, [])
Postgrex.query!(conn(), index, [])
:ok
end
def insert_job(job) do
{sql, params} = SQL.insert_params(job)
%{rows: [[id]]} = Postgrex.query!(conn(), sql <> " RETURNING id", params)
{:ok, %Job{job | id: id}}
end
def update_job(id, changes) do
{sql, params} = SQL.update_params(id, changes)
Postgrex.query!(conn(), sql, params)
:ok
end
def get_job(id) do
%{rows: rows} = Postgrex.query!(conn(), SQL.select_by_id(), [id])
case rows do
[row | _] -> {:ok, SQL.from_row(row)}
[] -> {:error, :not_found}
end
end
def list_jobs(opts) do
{sql, params} =
if status = Keyword.get(opts, :status) do
{SQL.select_by_status(), [Atom.to_string(status)]}
else
{SQL.select_all(), []}
end
%{rows: rows} = Postgrex.query!(conn(), sql, params)
Enum.map(rows, &SQL.from_row/1)
end
def cleanup(opts) do
{sql, params} = SQL.cleanup_params(Keyword.get(opts, :max_age_days, 7))
Postgrex.query!(conn(), sql, params)
:ok
end
defp conn, do: MyMusicServer.Repo
endThen wire it into your supervision tree:
# lib/my_music_server/application.ex
children = [
{Core.Workers.JobQueue, store: MyMusicServer.JobStore.Postgres, store_opts: []},
# ... rest of your children
]Step 6: Create Custom Worker (Optional)
If you need custom job processing logic:
Contract: Your worker module must implement
start_link/1that accepts a keyword list containing:id. The pool passes[id: i]whereiis the worker index (1..N).
# lib/my_music_server/music_worker.ex
defmodule MyMusicServer.MusicWorker do
use GenServer
require Logger
alias Core.Workers.JobQueue
@poll_interval 1_000
## ============================================================
## Public API
## ============================================================
def start_link(opts) do
worker_id = Keyword.get(opts, :id, 1)
name = :"#{__MODULE__}_#{worker_id}"
GenServer.start_link(__MODULE__, %{id: worker_id}, name: name)
end
## ============================================================
## GenServer Callbacks
## ============================================================
@impl true
def init(%{id: id} = state) do
Logger.info("Music Worker ##{id} started")
schedule_work()
{:ok, state}
end
@impl true
def handle_info(:work, state) do
case JobQueue.claim_next() do
{:ok, job} -> execute(job)
:empty -> :noop
end
schedule_work()
{:noreply, state}
end
## ============================================================
## Private Functions
## ============================================================
defp schedule_work do
Process.send_after(self(), :work, @poll_interval)
end
defp execute(job) do
Logger.info("Processing music job #{job.id}")
try do
result = case job.payload do
%{"task" => "transcode_audio", "file" => file, "format" => format} ->
transcode_audio(file, format)
%{"task" => "generate_waveform", "song_id" => id} ->
generate_waveform(id)
%{"task" => "extract_metadata", "file" => file} ->
extract_metadata(file)
%{"task" => "sync_library"} ->
sync_library()
_ ->
%{error: "Unknown task type"}
end
JobQueue.mark_done(job.id, result)
Logger.info("Music job #{job.id} completed successfully")
rescue
error ->
error_details = %{
error: Exception.message(error),
stacktrace: Exception.format_stacktrace(__STACKTRACE__)
}
Logger.error("Music job #{job.id} failed: #{inspect(error)}")
JobQueue.mark_failed(job.id, error_details)
end
end
defp transcode_audio(file, format) do
# Your custom audio processing logic
Logger.info("Transcoding #{file} to #{format}")
Process.sleep(500) # Simulate work
%{
status: "transcoded",
input: file,
output: "#{file}.#{format}",
format: format
}
end
defp generate_waveform(song_id) do
# Your custom waveform generation logic
Logger.info("Generating waveform for song #{song_id}")
Process.sleep(300) # Simulate work
%{
status: "generated",
song_id: song_id,
waveform_url: "/waveforms/#{song_id}.png"
}
end
defp extract_metadata(file) do
# Your custom metadata extraction
Logger.info("Extracting metadata from #{file}")
%{
status: "extracted",
title: "Song Title",
artist: "Artist Name",
album: "Album Name",
duration: 240
}
end
defp sync_library do
# Your custom library sync logic
Logger.info("Syncing music library")
Process.sleep(1000) # Simulate work
%{
status: "synced",
songs_added: 15,
songs_updated: 3,
songs_removed: 1
}
end
endStep 7: Set Up Your Application (Optional)
When using the library with config (Steps 1-3 above), the framework auto-starts. You only need a custom Application module if you want full control over the supervision tree.
Full Manual Control
# config/config.exs
config :servcore, start_http: false, start_workers: false
# lib/my_music_server/application.ex
defmodule MyMusicServer.Application do
use Application
require Logger
@impl true
def start(_type, _args) do
port = System.get_env("PORT", "5000") |> String.to_integer()
children = [
{Core.Workers.JobQueue,
store: Core.JobStore.SQLite,
store_opts: [database: "priv/jobs.db"]},
{Core.Workers.WorkerPool, worker: MyMusicServer.MusicWorker, size: 4},
{Plug.Cowboy,
scheme: :http,
plug: MyMusicServer.Router,
options: [port: port, ip: {0, 0, 0, 0}]},
MyMusicServer.Library,
MyMusicServer.Player
]
opts = [strategy: :one_for_one, name: MyMusicServer.Supervisor]
Supervisor.start_link(children, opts)
end
endStep 8: Add Required Dependencies
# mix.exs
defp deps do
[
{:servcore, path: "../servcore"},
{:plug_cowboy, "~> 2.7"},
{:jason, "~> 1.4"},
# Your domain-specific dependencies
{:id3, "~> 1.0"} # Example: MP3 metadata parsing
]
endStep 9: Run Your Server
mix deps.get
mix compile
mix run --no-halt
Your server is now running with:
- [✓] Job queue from core
- [✓] Custom routes for your domain
- [✓] Custom worker logic
- [✓] Your domain-specific services
Method 2: Fork the Repository
Use this method if you need to modify core framework functionality.
Step 1: Fork the Repository
git clone https://github.com/DarynOngera/ServCore.git my_music_server
cd my_music_server
# Update remote
git remote rename origin upstream
git remote add origin https://github.com/yourusername/my_music_server.git
Step 2: Rename the Project
# mix.exs
defmodule MyMusicServer.MixProject do
use Mix.Project
def project do
[
app: :my_music_server, # Changed from :elixir_server_core
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {MyMusicServer.Application, []} # Changed
]
end
defp deps do
[
{:telemetry, "~> 1.2"},
{:telemetry_metrics, "~> 0.6"},
{:plug_cowboy, "~> 2.7"},
{:jason, "~> 1.4"},
# Add your deps here
]
end
endStep 3: Modify Core Files
Now you can directly modify:
lib/core/workers/job_queue.ex- Change queue behaviorlib/core/http/router.ex- Modify base routeslib/core/workers/worker.ex- Change worker logic
Step 4: Keep Core Updated (Optional)
To pull updates from the original core:
# Fetch upstream changes
git fetch upstream
# Merge into your fork
git merge upstream/main
# Resolve conflicts and commit
Complete Example: Music Server
Here's a complete, working music server implementation:
Project Structure
my_music_server/
├── lib/
│ ├── my_music_server/
│ │ ├── application.ex # Supervision tree
│ │ ├── router.ex # HTTP routes
│ │ ├── music_worker.ex # Background jobs
│ │ ├── library.ex # Song database
│ │ ├── player.ex # Playback control
│ │ └── playlist_manager.ex # Playlist logic
│ └── my_music_server.ex # Module root
├── mix.exs
└── README.mdLibrary Module
# lib/my_music_server/library.ex
defmodule MyMusicServer.Library do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def all_songs do
GenServer.call(__MODULE__, :all_songs)
end
def get_song(id) do
GenServer.call(__MODULE__, {:get_song, id})
end
@impl true
def init(_) do
# Initialize with some sample data
songs = %{
"1" => %{id: "1", title: "Song One", artist: "Artist A", duration: 240},
"2" => %{id: "2", title: "Song Two", artist: "Artist B", duration: 180},
"3" => %{id: "3", title: "Song Three", artist: "Artist A", duration: 200}
}
{:ok, songs}
end
@impl true
def handle_call(:all_songs, _from, songs) do
{:reply, Map.values(songs), songs}
end
@impl true
def handle_call({:get_song, id}, _from, songs) do
case Map.get(songs, id) do
nil -> {:reply, {:error, :not_found}, songs}
song -> {:reply, {:ok, song}, songs}
end
end
endPlayer Module
# lib/my_music_server/player.ex
defmodule MyMusicServer.Player do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{current: nil}, name: __MODULE__)
end
def play(song_id) do
GenServer.call(__MODULE__, {:play, song_id})
end
def stop do
GenServer.call(__MODULE__, :stop)
end
def current do
GenServer.call(__MODULE__, :current)
end
@impl true
def init(state), do: {:ok, state}
@impl true
def handle_call({:play, song_id}, _from, state) do
case MyMusicServer.Library.get_song(song_id) do
{:ok, _song} ->
{:reply, :ok, %{state | current: song_id}}
{:error, :not_found} ->
{:reply, {:error, "Song not found"}, state}
end
end
@impl true
def handle_call(:stop, _from, state) do
{:reply, :ok, %{state | current: nil}}
end
@impl true
def handle_call(:current, _from, state) do
{:reply, state.current, state}
end
endPlaylist Manager
# lib/my_music_server/playlist_manager.ex
defmodule MyMusicServer.PlaylistManager do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def create(name, song_ids) do
GenServer.call(__MODULE__, {:create, name, song_ids})
end
def all do
GenServer.call(__MODULE__, :all)
end
@impl true
def init(_), do: {:ok, %{}}
@impl true
def handle_call({:create, name, song_ids}, _from, playlists) do
id = System.unique_integer([:positive]) |> Integer.to_string()
playlist = %{
id: id,
name: name,
song_ids: song_ids,
created_at: DateTime.utc_now()
}
updated = Map.put(playlists, id, playlist)
{:reply, {:ok, playlist}, updated}
end
@impl true
def handle_call(:all, _from, playlists) do
{:reply, Map.values(playlists), playlists}
end
endTesting Your Music Server
# Start the server
mix run --no-halt
# In another terminal, test the API:
# Health check
curl http://localhost:5000/health
# Get all songs
curl http://localhost:5000/songs | jq
# Get specific song
curl http://localhost:5000/songs/1 | jq
# Play a song
curl -X POST http://localhost:5000/songs/1/play
# Create a playlist
curl -X POST http://localhost:5000/playlists \
-H "Content-Type: application/json" \
-d '{"name": "My Favorites", "song_ids": ["1", "3"]}'
# Submit a background job
curl -X POST http://localhost:5000/jobs \
-H "Content-Type: application/json" \
-d '{"payload": {"task": "transcode_audio", "file": "song.wav", "format": "mp3"}}'
# Check job status
curl http://localhost:5000/jobs | jq
Tips and Best Practices
1. Use Core Components Selectively
You don't have to use everything. Pick what you need:
# Minimal setup - just job queue
children = [
Core.Workers.JobQueue,
MyApp.CustomWorker
]
# No HTTP server needed if you're building a background processor2. Override Default Worker Behavior
# Use core queue but custom worker
children = [
Core.Workers.JobQueue,
MyApp.Worker # Your implementation
]3. Keep Core Logic Unchanged
When using as a library, you get automatic updates:
# Update core to latest version
mix deps.update servcore
4. Environment Configuration
# config/config.exs
config :my_music_server,
port: 5000,
music_directory: "/path/to/music",
worker_poll_interval: 1000
# In your application
port = Application.get_env(:my_music_server, :port)5. Testing Your Fork
# test/my_music_server_test.exs
defmodule MyMusicServerTest do
use ExUnit.Case
alias Core.Workers.JobQueue
setup do
{:ok, _} = Application.ensure_all_started(:my_music_server)
:ok
end
test "submits audio processing job" do
{:ok, id} = JobQueue.submit(%{
"task" => "transcode_audio",
"file" => "test.wav",
"format" => "mp3"
})
assert is_integer(id)
# Wait for processing
:timer.sleep(2000)
{:ok, job} = JobQueue.get(id)
assert job.status == :done
assert job.result["status"] == "transcoded"
end
endTroubleshooting
Issue: Can't find Core modules
Problem: Core.Workers.JobQueue is undefined
Solution: Make sure you added the dependency and ran:
mix deps.get
Issue: Port already in use
Solution:
PORT=5001 mix run --no-halt
Issue: Jobs not processing
Check:
- Worker is started in supervision tree
- JobQueue is started before Worker
- Check logs for errors
Issue: Routes not working
Check:
- Router is passed to Plug.Cowboy correctly
- Routes are defined before
match _catch-all - Plugs are in correct order
Next Steps
- Add persistence: Store jobs/data in PostgreSQL
- Add authentication: Secure your API endpoints
- Add WebSockets: Real-time updates for job progress
- Add metrics: Monitor performance with Prometheus
- Deploy: Containerize with Docker
Support
License
MIT License - Fork freely!