BlogEngine
A tenant-aware blog domain engine for Elixir and PostgreSQL.
BlogEngine owns the blog domain: posts, immutable revisions, review and publication lifecycles, authors, tags, route history, audit events, and curated cross-blog syndication. Your application owns identity, authorization policy, HTTP, and presentation.
Why BlogEngine?
- Drop-in blog domain — every project hand-wires the same posts, drafts, tags, and publishing rules, or pulls in an overly complex CMS.
- Multi-tenant by default — every operation runs through a context carrying a
tenant_key; tenant isolation is enforced by the engine, not by caller discipline. - Immutable revisions — published content is never edited in place. Every change is a revision with a full review trail (submit, approve, reject) and audit events.
- Host-owned boundary — no routes, controllers, or LiveViews are mounted. Your app supplies authorization, route coordination, and event delivery through three small behaviours; optional generators scaffold editor and public UIs you then own.
- Postgres is the source of truth — the schema installs into its own PostgreSQL prefix through EctoEvolver raw SQL migrations, wrapped in a host-owned, version-pinned Ecto migration.
Prerequisites
- Elixir 1.15+
- PostgreSQL with a host Ecto repo (Ecto SQL + Postgrex)
Installation
Add blog_engine to your dependencies in mix.exs:
def deps do
[
{:blog_engine, "~> 0.1.0"}
]
end
Then fetch dependencies:
mix deps.get
Igniter hosts can instead run mix igniter.install blog_engine, which performs the
setup below automatically.
Setup
Configure the host repo and PostgreSQL prefix:
# config/config.exs
config :my_app, ecto_repos: [MyApp.Repo]
config :blog_engine, repo: MyApp.Repo, prefix: "blog_engine"
The prefix is host-configurable; for example, an existing application can use
prefix: "custom_blog" consistently in its configuration, generated migration,
and runtime contexts.
Generate the pinned host migration, run it, and verify the installed schema:
mix blog_engine.setup --repo MyApp.Repo --prefix blog_engine
mix ecto.migrate
mix blog_engine.check_schema --repo MyApp.Repo --prefix blog_engine
blog_engine.setup delegates to the host's ecto.gen.migration. It generates a
host-owned Ecto migration wrapper pinned to the installed schema version and never
runs a migration itself. Commit the wrapper so package upgrades cannot silently change an
already-deployed migration. Later upgrades use
mix blog_engine.gen.migration --from N --to N+1.
One host per BEAM VM. The
:blog_engineapplication environment describes exactly one host: one repo, one prefix, one set of adapters. To run multiple hosts in one VM, passrepo:,prefix:, and the adapter modules explicitly to everyBlogEngine.Context.new/1call.
Usage
Every operation receives a context: tenant key, actor, repo/prefix, and host adapters.
context =
BlogEngine.Context.new(
repo: MyApp.Repo,
prefix: "blog_engine",
tenant_key: tenant.id,
actor: %{type: "user", key: user.id},
authorizer: MyApp.BlogAuthorizer,
route_registry: MyApp.BlogRouteRegistry,
notifier: MyApp.BlogNotifier
)
Author and Publish
{:ok, author} = BlogEngine.Authors.create(context, %{name: "Ada Editor", slug: "ada-editor"})
{:ok, blog} =
BlogEngine.Blogs.create(context, %{
publisher_type: "publication",
publisher_key: publisher.id,
name: "News",
default_author_id: author.id,
index_route_template: "/news",
post_route_template: "/news/:post_slug"
})
{:ok, draft} =
BlogEngine.Posts.create_draft(context, blog.id, %{
author_id: author.id,
slug: "opening-day",
title: "Opening day",
content_markdown: "# Welcome"
})
{:ok, published} = BlogEngine.Posts.publish(context, draft.id)
Review Lifecycle
Revisions are immutable; edits produce a new working revision that moves through review:
{:ok, post} = BlogEngine.Posts.update_working_revision(context, post.id, %{title: "Updated"})
{:ok, post} = BlogEngine.Posts.submit(context, post.id)
{:ok, post} = BlogEngine.Posts.approve_revision(context, post.id, revision_id)
# or: BlogEngine.Posts.reject_revision(context, post.id, revision_id, "needs sources")
# Moderation
BlogEngine.Posts.unpublish(context, post.id)
BlogEngine.Posts.archive(context, post.id)
BlogEngine.Posts.withhold(context, post.id, "legal review")
BlogEngine.Posts.restore(context, post.id)
Public Reads and Routing
{:ok, %BlogEngine.Page{entries: entries}} = BlogEngine.Public.list_feed(context, blog.id)
{:ok, post} = BlogEngine.Public.get_post_by_slug(context, blog.id, "opening-day")
{:ok, tags} = BlogEngine.Public.list_tags_with_counts(context, blog.id)
# Resolve any canonical path (index, post, or historical redirect)
BlogEngine.Routing.resolve(context, "/news/opening-day")
Route history is first-class: moving a blog's templates or renaming a published post retains the former path so hosts can serve permanent redirects.
Tags and Syndication
{:ok, tag} = BlogEngine.Tags.find_or_create(context, "Engineering")
# Curated cross-blog placement: request from the source, approve at the target
{:ok, placement} = BlogEngine.Syndication.request(context, post.id, target_blog.id)
{:ok, placement} = BlogEngine.Syndication.approve(context, placement.id)
Host Behaviours
| Behaviour | Responsibility |
|---|---|
BlogEngine.Authorizer | Re-query host roles/ownership per operation — never trusts caller maps |
BlogEngine.RouteRegistry | Coordinate canonical paths with the host's other routes, inside the transaction |
BlogEngine.Notifier | Receive committed events; durable delivery and retry stay host-owned |
The engine enforces tenant, lifecycle, revision, and route invariants after host authorization succeeds.
HTTP, JSON, OpenAPI, and frontend contracts are host-owned; BlogEngine exposes only the in-process domain capabilities and extension boundaries hosts build on.
Mix Tasks
| Task | Description |
|---|---|
mix blog_engine.setup | Generate the initial pinned host migration |
mix blog_engine.gen.migration | Generate one adjacent version-upgrade wrapper |
mix blog_engine.check_schema | Verify the installed schema version (CI/deploy gate) |
mix blog_engine.gen.editor | Scaffold a host-owned Phoenix editor UI (optional) |
mix blog_engine.gen.public | Scaffold host-owned public blog pages (optional) |
mix blog_engine.gen.ai | Scaffold a ReqLLM-backed writing assistant (optional) |
Generated files are conflict-safe, host-owned scaffolds. BlogEngine upgrades never overwrite them, and no runtime UI or routes are mounted by the library itself.
Guides
guides/installation.md— prefixes, upgrades, backups, contexts, lifecycle examples, and extension behaviours- CHANGELOG — releases
Testing
mix test
mix check # format check, compile --warnings-as-errors, credo --strict, doctor, test
Integration tests compile disposable host projects and resolve real Hex dependencies, so they are excluded from the default run:
mix test --include integration
Quality gates used by this repo:
mix quality # compile --warnings-as-errors, format check, sobelow, ex_dna, doctor, credo --strict
mix dialyzer
License
MIT