Adding Dev.to as a syndication target
I already have a Rails app that imports posts to Medium and triggers a LinkedIn announcement once they publish. Adding Dev.to felt like the obvious next step since it’s where a lot of the developer audience actually is. I figured it’d be quick since the architecture was already there.
It mostly was. But there were a few things that surprised me.
The API is just better
Medium’s API is deprecated. I have to use a token that expires, poll an RSS feed to detect when a draft gets published, and inject <figure> tags for hero images because the API doesn’t have a main_image field. The whole thing is held together with string parsing.
Dev.to is the opposite. The Forem API is documented, actively maintained, and does what you’d expect: POST /api/articles creates a draft, PUT /api/articles/{id} updates it, GET /api/articles/me/all returns your full article list with published_at timestamps. No RSS. No scraping. Hero image is just a field in the payload.
The auth is also simpler: a single api-key header from your account settings, stored as an encrypted column on the Blog model.
Mirroring the Medium architecture
My Medium integration uses a pattern where every service that makes HTTP calls takes a connection: Proc that returns a fake response in tests. This lets me test error branches (401, 429, 5xx) without WebMock or VCR; just pass in a lambda that returns a Struct.new(:code, :body).
I kept the same pattern for Devto::Client:
def initialize(token:, connection: default_connection)
@token = token
@connection = connection
end
def create_article(payload:)
response = request(Net::HTTP::Post, "/api/articles", payload)
raise AuthError, "..." if response.code == "401"
JSON.parse(response.body)
end
The rest of the service layer mirrors Medium almost exactly: DraftCreator, Publisher, PublishedSync. The main difference is PublishedSync replaces both Medium’s RssPoller and PublishedBackfill, because the API returns all articles with their published_at, so there’s nothing to separately backfill.
The LinkedIn announce refactor
Once I had two syndication platforms, the LinkedIn announcement logic needed to live somewhere shared. It was previously inline in MediumSyncJob, a private method that checked token expiry, retry counts, and idempotency.
I extracted it to Syndication::LinkedInAnnounce, a small PORO that takes a blog and loops over eligible posts:
def eligible_posts
@blog.posts
.where(medium_status: Post.medium_statuses[:published])
.or(@blog.posts.where(devto_status: Post.devto_statuses[:published]))
.select { |p| p.linkedin_not_posted? || (p.linkedin_failed? && p.linkedin_attempts < 3) }
end
The .or() query means a post published on either platform triggers LinkedIn. The select handles idempotency: if the post already has linkedin_status: :posted, it’s skipped. A post published on both Medium and Dev.to only announces once.
Both MediumSyncJob and DevtoSyncJob now call announcer_class.new(blog).call, where announcer_class is a class_attribute defaulting to Syndication::LinkedInAnnounce, injectable in tests without any stubbing magic.
The Zeitwerk gotcha
I named the class Syndication::LinkedinAnnounce (lowercase “in”). Seemed natural. The tests blew up immediately with NameError: uninitialized constant Syndication::LinkedinAnnounce.
The issue: config/initializers/inflections.rb has inflect.acronym "LinkedIn". Zeitwerk uses the Rails inflector to map filenames to constant names, so linkedin_announce.rb gets mapped to LinkedInAnnounce, not LinkedinAnnounce. The class name and the expected constant diverged silently.
The fix was to rename the class to Syndication::LinkedInAnnounce. Worth knowing: any service file whose name contains a registered acronym (OAuth, GitHub, AWS) has to use the inflected form or Zeitwerk won’t find it.
Testing
Same Minitest conventions as the rest of the app: no WebMock, no VCR. The job tests use class_attribute injection:
setup do
@original_sync = DevtoSyncJob.sync_class
@original_announcer = DevtoSyncJob.announcer_class
end
teardown do
DevtoSyncJob.sync_class = @original_sync
DevtoSyncJob.announcer_class = @original_announcer
end
Save the original, inject a fake, restore in teardown. Works cleanly with parallel test execution because each test process gets its own class instance.
The idempotency test is explicit; a post published on both platforms should only appear once in the announcer’s call log:
test "idempotent: a post published on both Medium and Dev.to is only announced once" do
post = @blog.posts.create!(slug: "p", medium_status: :published, devto_status: :published, ...)
calls = []
Syndication::LinkedInAnnounce.new(@blog, creator_class: creator_class(calls)).call
assert_equal 1, calls.count { |p| p.id == post.id }
end
What I’d do differently
The tag sanitization (gsub(/[^a-z0-9]/i, "").downcase) is rough. “C++” becomes “c”, “C#” becomes “c”. Dev.to has tag constraints (lowercase, alphanumeric, max 4), but I should handle common cases better, or at least log when a tag gets mangled so it’s visible. Right now it fails silently.
I also skipped username discovery. The API has GET /users/me that would let me store devto_username on the Blog model. Skipped for now since the article URL comes back in the create response anyway.
Next
Hashnode is on the list. Same pattern, different API shape. At this point the syndication architecture is stable enough that adding a third platform should be mostly additive.
Related reading
Deleting an integration by moving it to Postiz
Routing Dev.to publishing through Postiz and deleting the native client: the DTO that validates stricter than the runtime, the draft that isn't, and a cover image lost to local storage.
What happens when a job broadcasts to nobody
Closing the hero-image loop: insert-only frontmatter patching, a guard that caught real drift on its first run, and a Turbo broadcast with no listener.
A doc-drift fix that wasn't as boring as it sounded
Three audit items that each turned into something: a half-fixed claim, a quietly dead password reset, and a staging email that would have linked to production.
You might also find useful
Proton Pass
Privacy-focused password manager from the team behind Proton Mail.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn moreProton Drive
Encrypted cloud storage from the team behind Proton Mail.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn moreAiralo eSIM
Local data eSIM for travel - no physical SIM swap needed.
This is my Airalo referral link. You get a discount on your first eSIM, and I earn Airalo credit toward mine.
Learn more