Deleting a publishing integration I just built
A few weeks ago I wired Hashnode into blog-manager: a GraphQL client, a draft creator, a published-state sync job, a publisher, columns on two tables, form fields, status badges, a nightly cron. This week I deleted all of it. Here’s why, and what removing a feature taught me about the shape I actually want.
What I was trying to do
blog-manager syndicates my posts from GitHub out to other platforms. The original plan was “one native integration per platform” - Medium, Dev.to, Hashnode, LinkedIn, each with its own API client and its own publish flow. That’s a clean mental model right up until you have four of them and notice they’re 80% the same code with different field names.
Then I added Postiz (a self-hosted social scheduler) for the social side, and the duplication got worse. A blog post could now go to LinkedIn two ways: natively, or as a Postiz channel. I’d already torn out the native LinkedIn path for exactly that reason. Hashnode was the next domino. I never connected a real Hashnode account, so it was pure dead weight - code I’d have to keep green in every future refactor for a platform I don’t use.
What I removed
The grep was the honest accounting. 31 files touched hashnode:
app/services/hashnode/{client,draft_creator,published_sync,publisher}.rb
app/jobs/hashnode_{import,sync}_job.rb
lib/tasks/hashnode.rake
+ columns on posts (7) and blogs (3), two indexes
+ enums, controller actions, routes, a nightly cron
+ form sections, table columns, filters, a status-badge preset
+ all the tests for the above
Final diff: 31 files, +14 / -1126. Deleting features is the only time the line count moves in the satisfying direction.
The migration mirrored the LinkedIn one - a reversible remove_column/remove_index with full type args, so it rolls back cleanly:
def change
remove_index :posts, :hashnode_status
remove_index :posts, :hashnode_import_state
remove_column :posts, :hashnode_status, :integer, default: 0, null: false
# ...7 post columns, 3 blog columns
end
Decisions I made along the way
The grep lied a little, and that’s the interesting part. My first sweep filtered by file extension - .rb, .erb, .yml, .md. It missed lib/tasks/hashnode.rake because .rake wasn’t in my list. I only caught it on a second, unfiltered pass at the end. Lesson I keep relearning: when you’re removing something “entirely,” the verification grep has to be broader than the editing grep. The thing you forget to search is the thing that survives.
One decision the ticket explicitly left open: a constant called NATIVE_BLOG_PROVIDERS excludes Medium/Dev.to/Hashnode from the Postiz channel picker, so a post can’t be cross-posted twice (full article natively + a short blurb via Postiz). Once native Hashnode is gone, should hashnode stay in that exclusion list? I removed it. There’s no double-post risk anymore, and dropping it means if I ever connect a Hashnode channel in Postiz, it becomes selectable like any other social target. That choice quietly previews where this is all heading.
The ticket’s spec was a suggestion, not a map. It listed columns and files to delete, but it predated a later feature (the URL-source picker) that had since added hashnode_url into the Post model’s SHARE_SOURCES, shareable_urls, and share_url. None of those were in the removal checklist. “Remove it entirely” only works if you re-derive the blast radius from the current code, not from a ticket written against an older tree.
What surprised me
Removing Hashnode didn’t feel like cleanup. It felt like the codebase asking a question I’d been avoiding: why do I have native publishers at all?
Right now Medium and Dev.to still publish natively - full article, my own API client, my own draft-then-publish dance. Everything social goes through Postiz. That split made sense when I built it (“blog platforms native, social via Postiz”), but after deleting Hashnode I can’t cleanly defend the boundary. It’s not “blog vs social,” it’s “the two I happened to build first vs everything else.” That’s history, not architecture.
What’s next
I’m opening a spike to figure out the right publishing and advertising workflow end to end: which platforms stay native and why, which move behind Postiz, and what the publish action should even mean when one post fans out to a canonical URL plus N cross-posts plus M social blurbs. Deleting one integration was easy. Deciding what the remaining ones should be is the actual work. More on that once the spike lands.
Related reading
Add a feature, or move a responsibility?
Adding Postiz social cross-posting looked done until a blunt question exposed a double-post bug, and a full audit of every posting path in the app found two more like it.
The Postiz webhook I couldn't build, and polling instead
A spike that ended in "don't build this": private-IP rejection, an unauthenticated empty payload, and the pull API that had everything the push lacked.
Scheduling and smarter link-sharing for my Postiz cross-poster
Future scheduling, richer blurbs with thumbnails, and a which-URL picker, plus the wall-clock timezone bug and the import that copied the wrong column.