Skip to content
Development

Adding Dev.to as a syndication target

By Victor Da Luz
railsrubydevtodev-logblog-manager

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

Development

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.

Read