Hex.pm Hex Docs License

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_engine application environment describes exactly one host: one repo, one prefix, one set of adapters. To run multiple hosts in one VM, pass repo:, prefix:, and the adapter modules explicitly to every BlogEngine.Context.new/1 call.

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

BehaviourResponsibility
BlogEngine.AuthorizerRe-query host roles/ownership per operation — never trusts caller maps
BlogEngine.RouteRegistryCoordinate canonical paths with the host's other routes, inside the transaction
BlogEngine.NotifierReceive 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

TaskDescription
mix blog_engine.setupGenerate the initial pinned host migration
mix blog_engine.gen.migrationGenerate one adjacent version-upgrade wrapper
mix blog_engine.check_schemaVerify the installed schema version (CI/deploy gate)
mix blog_engine.gen.editorScaffold a host-owned Phoenix editor UI (optional)
mix blog_engine.gen.publicScaffold host-owned public blog pages (optional)
mix blog_engine.gen.aiScaffold 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

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