Skip to main content
Rails Engineering

Xuất bản, kiểm duyệt và quản lý bài viết của bạn.

#rails

Building a Reliable Publishing Workflow in Ruby on Rails

Tienbob Tienbob • • 4 min read
Building a Reliable Publishing Workflow in Ruby on Rails

Building a Reliable Publishing Workflow in Ruby on Rails

A blog becomes more interesting—and more difficult to maintain—when publishing involves more than saving a title and body. Authors need drafts, reviewers need moderation tools, and readers should only see approved content.
This article builds a publishing workflow with explicit state transitions, database transactions, and background jobs.
A reliable publishing workflow makes invalid actions difficult and valid actions easy to understand.
1. Define the Publishing Lifecycle
Our example uses four states:
  1. Draft — The author is still editing.
  2. Pending review — The post is ready for moderation.
  3. Published — Readers can access the post.
  4. Rejected — The reviewer has requested changes.
The workflow should support these rules:
  • Authors can submit drafts for review.
  • Only pending posts can be approved.
  • Rejected posts can be edited and submitted again.
  • Published posts must have a publication timestamp.
  • Publishing should enqueue a notification job.
For more background on model behavior, see the Rails Active Record guide.
2. Create the Database Structure
Store the state as an integer and add an index for queries that retrieve published posts.
class CreatePosts < ActiveRecord::Migration[7.1]
  def change
    create_table :posts do |t|
      t.references :author,
                   null: false,
                   foreign_key: { to_table: :users }

      t.string :title, null: false
      t.text :body, null: false
      t.integer :status, null: false, default: 0
      t.datetime :published_at
      t.text :rejection_reason

      t.timestamps
    end

    add_index :posts, [:status, :published_at]
  end
end
The migration superclass version should match your application's migration version.
3. Model the Business Rules
The model validates content and provides methods for transitions. Disabling generated enum instance methods lets us expose deliberate workflow methods instead of convenient status setters such as published!.
class Post < ApplicationRecord
  class InvalidTransition < StandardError; end

  belongs_to :author, class_name: "User"

  enum :status, {
    draft: 0,
    pending_review: 1,
    published: 2,
    rejected: 3
  }, instance_methods: false

  validates :title, presence: true, length: { maximum: 180 }
  validates :body, presence: true
  validates :published_at, presence: true, if: :published?
  validates :rejection_reason, presence: true, if: :rejected?

  scope :recently_published, -> {
    published.order(published_at: :desc)
  }

  def published?
    status == "published"
  end

  def rejected?
    status == "rejected"
  end

  def submit_for_review!
    with_lock do
      unless %w[draft rejected].include?(status)
        raise InvalidTransition,
              "Only draft or rejected posts can be submitted"
      end

      update!(
        status: :pending_review,
        rejection_reason: nil
      )
    end
  end
end
with_lock serializes competing transitions on the same post. The state check happens inside the lock so it uses the locked record's current state.
These methods establish an application convention. Other code must still avoid assigning status directly and bypassing the workflow.
4. Publish Through a Service Object
Publishing coordinates a state transition with a follow-up action. A small service keeps that operation easy to find.
module Posts
  class Publish
    def initialize(post:)
      @post = post
    end

    def call
      @post.with_lock do
        unless @post.status == "pending_review"
          raise Post::InvalidTransition,
                "Only pending posts can be published"
        end

        @post.update!(
          status: :published,
          published_at: Time.current,
          rejection_reason: nil
        )
      end

      PostPublishedNotificationJob.perform_later(@post.id)

      @post
    end
  end
end
In this example, the service owns the transaction boundary and is called outside another transaction.
Saving a database record and submitting a background job are two separate operations. Successful publishing does not automatically guarantee successful notification delivery.
If the queue is unavailable after the update commits, the post can be published without a notification job. For stronger delivery guarantees, an outbox pattern can persist a notification event in the same database transaction and let a worker deliver it later.
5. Keep the Controller Focused
The controller authenticates the request, checks authorization, and delegates publishing.
class PublicationsController < ApplicationController
  before_action :authenticate_user!

  def create
    post = Post.find(params[:post_id])

    # Application-specific authorization:
    # this must verify that current_user can publish this post.
    authorize_publish!(post)

    Posts::Publish.new(post: post).call

    redirect_to post_path(post),
                notice: "Your post is now published."
  rescue Post::InvalidTransition => error
    redirect_to post_path(post),
                alert: error.message
  end
end
Authorization must run on the server. Hiding a publish button in the UI does not prevent someone from submitting the request directly.
6. Test the Rules That Matter
A useful test verifies observable behavior: publication changes the state, records a timestamp, and schedules the notification.
require "test_helper"

class Posts::PublishTest < ActiveSupport::TestCase
  include ActiveJob::TestHelper

  test "publishes a pending post and schedules notification" do
    post = Post.create!(
      author: users(:author),
      title: "Understanding Rails Transactions",
      body: "A detailed explanation with practical examples.",
      status: :pending_review
    )

    assert_enqueued_with(
      job: PostPublishedNotificationJob,
      args: [post.id]
    ) do
      Posts::Publish.new(post: post).call
    end

    post.reload

    assert_equal "published", post.status
    assert_not_nil post.published_at
  end

  test "prevents publishing a draft" do
    post = Post.create!(
      author: users(:author),
      title: "An Unfinished Article",
      body: "Still working on this article.",
      status: :draft
    )

    assert_no_enqueued_jobs do
      assert_raises(Post::InvalidTransition) do
        Posts::Publish.new(post: post).call
      end
    end

    assert_equal "draft", post.reload.status
    assert_nil post.published_at
  end
end
This test assumes an author fixture and a notification job already exist.
Improvements for a Larger Blog
As the application grows, consider adding:
  • Revision history to preserve earlier versions.
  • Reviewer comments to explain moderation decisions.
  • Audit records to track who approved each post.
  • Scheduled publishing for future publication dates.
  • Idempotent notifications to tolerate job retries.
  • Rich text sanitization for user-submitted content.
Useful documentation:
  1. Active Record validations
  2. Active Job basics
  3. Rails testing guide
  4. Rails security guide
A publishing workflow deserves the same care as any other business process: explicit rules, controlled transitions, and tests that verify what users actually experience.

Share this article
Tienbob

Tienbob

Cảm xúc

1
Bày tỏ cảm xúc Di chuột để xem 1

Bình luận

Đăng nhập để tham gia cuộc trò chuyện.

Recommended Reading