#rails
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:
- Draft — The author is still editing.
- Pending review — The post is ready for moderation.
- Published — Readers can access the post.
- 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
endThe 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
endwith_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
endIn 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
endAuthorization 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
endThis 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:
A publishing workflow deserves the same care as any other business process: explicit rules, controlled transitions, and tests that verify what users actually experience.
Tienbob
Cảm xúc
1
Bày tỏ cảm xúc
Di chuột để xem
1
Thích
Yêu thích
Haha
Wow
Buồn
Giận dữ
Bình luận
Đăng nhập để tham gia cuộc trò chuyện.