Building a writing platform sounds simple until you sit down to actually do it. You realize you need users, works, chapters, genres, follower systems, image storage, and sentiment analysis, and those concepts all need to talk to each other in ways that don’t collapse under their own weight. Sin Pluma is my attempt at solving that problem, and it earned second place at the DIVEC Project Exposition 2019B, not for the concept alone but for the engineering behind it. This series walks through every design decision that went into it.
What Sin Pluma is and who it’s for
Sin Pluma is a SaaS writing platform where authors publish long-form creative works and readers follow them. Think serialized fiction, essay collections, poetry, and experimental prose. The name means “without a pen” in Spanish, which captures the idea of digital writing freed from physical constraints.
The platform targets two types of users. Authors need a place to organize their works into notebooks, write and edit chapters (called pages), upload cover images, and see how readers engage with their content. Readers need to browse works by genre, follow what interests them, and read chapters in a clean interface. Both groups interact with the same data through the same API, just with different permissions and views.
What makes Sin Pluma interesting from a technical standpoint is the choices made in implementation. It’s API-first, meaning every feature exists as an HTTP endpoint before any UI touches it. The frontend is a full React SPA. The database runs as a MySQL InnoDB Cluster for high availability. Image uploads go to MinIO. Sentiment analysis runs in a separate microservice. And all of this connects through Nginx and Docker Compose.
Architectural goals
Several design goals shaped the system from the start, and they’re worth naming explicitly because they explain decisions that might otherwise look arbitrary.
The platform follows an API-first model. Every operation inside the application flows through a JSON endpoint. The React client consumes the same interface a mobile app or an external client could use. There’s no server-rendered HTML, no shortcut that bypasses the contract.
High availability for the database came next. User accounts and written content represent the core of the platform. Storing everything in a single database instance felt wrong for a system meant to demonstrate realistic infrastructure. A three-node InnoDB Group Replication cluster with MySQL Router was the answer.
Separation of concerns influenced the architecture throughout. Transactional logic belongs in the API layer. Media files belong in object storage. Text analysis should run independently from the main application. Each service has a clear boundary and a clear job.
Automation was the final requirement. The system needed to bootstrap itself automatically inside Docker Compose without manual intervention. A MySQL Shell script handles the entire cluster initialization sequence, including schema import, so a fresh environment comes online with a single docker-compose up.
The domain vocabulary
Before touching any code, you need a shared language for the domain. Sin Pluma’s model has eight core concepts.
A User is anyone with an account. Users have a username, email, first name, last name, and a bcrypt-hashed password. The user_created timestamp records when they joined.
A Notebook is what an author calls their work: a novel, a collection, a series. Each notebook belongs to one user, belongs to one genre, has a title, and has a synopsis (resume). The notebook is the top-level container.
A Page is a chapter or section within a notebook. Pages have a title, content stored as rich JSON from the Slate editor, and a position integer that orders them within their parent notebook. When you delete a page, the system automatically re-numbers the remaining pages.
A Genre is a classification label: fiction, science fiction, poetry, horror, and so on. The database seeds ten genres on first boot. Genres are simple lookup records that notebooks reference by foreign key.
A Reading is the follow relationship between a user and a notebook. When a reader follows a work, a reading record is created. When they unfollow, it’s deleted. This is a many-to-many join between users and notebooks.
A Tag exists in the schema as a future feature. The tags and has_tags tables are defined, but the current API doesn’t expose tag management yet. The data model is ready; the endpoints aren’t.
An Image isn’t a database entity in the traditional sense. Profile images and notebook cover images are stored as objects in MinIO, keyed by the user or notebook ID. The API proxies GET and POST requests to MinIO, and only metadata references live in MySQL.
A Sentiment is the output of the linguistics microservice. When an author triggers analysis in the editor, the frontend sends each sentence to the /lin/sentiment endpoint, which returns positive or negative. The editor highlights the result inline using Slate annotations.
The core user journeys
Understanding the system is easier if you trace what a user actually does rather than reading the data model in isolation.
Creating and publishing a work
An author registers, logs in, and gets back an access token and a refresh token. They navigate to the create-work page, fill in a title, pick a genre, and write a synopsis. The frontend POSTs to /notebooks/ with their user ID embedded from the JWT claims. The work appears in their profile immediately.
From the work detail page they can add pages. Each new page starts empty. The editor loads the page content from /pages/{id}?mode=editor and auto-saves on every change by PUTting to the same endpoint. The author can also upload a cover image, which the frontend sends as multipart form data to /notebooks/{id}/image/. Flask receives the file, streams it to MinIO, and returns a reference URL that the frontend stores.
Reading and following
A reader visits the works browse page, which calls /notebooks/ to list everything, then fetches genres in parallel to display labels. The reader clicks a work, lands on its detail page, and can click “Seguir” (follow). That sends a POST to /readings with the notebook ID. The JWT on the request tells the server which user is following.
If the reader is the author of the work, the follow button doesn’t appear. The frontend checks the current user’s ID against the notebook’s user_id using two higher-order components called DrawIfAuth and DrawIfNotAuth.
Running sentiment analysis
Inside the editor there’s an “Analizar” button. The author clicks it and the frontend iterates over every text node in the Slate document, splits them by sentence boundaries (period or newline), and sends each sentence to /lin/sentiment?key={key}&sentence={text}. The linguistics service runs inference on a pre-trained scikit-learn logistic regression model and returns positive or negative. The editor colors each sentence using Slate’s annotation system. Clicking analyze again clears all annotations and the cycle resets.
What makes the system design worth studying
Several properties set Sin Pluma apart from a typical classroom project.
API-first design means the backend has no knowledge of the frontend. Every interaction goes through explicit HTTP contracts. You could swap the React SPA for a mobile app or a CLI and the backend wouldn’t change.
Microservices with clear boundaries. The linguistics service is fully isolated. It loads a pickled scikit-learn model at startup, exposes one endpoint, and has no database connection. Flask, gunicorn, done. No shared code with the main API.
High availability database. Using MySQL InnoDB Cluster instead of a single database instance means the system can survive node failures. MySQL Router sits in front of three MySQL nodes and handles read/write distribution and failover automatically. From the application’s perspective, the cluster looks like a single connection string pointing to the router.
JWT-based auth with Redis blacklisting. Access tokens expire in 15 minutes. Refresh tokens last 30 days. Logging out doesn’t just discard the token client-side; it stores the JWT ID in Redis so the server rejects it on any future request. This hybrid approach gives stateless JWT most of its benefits while still supporting real logout behavior.
Fully automated infrastructure. Typing docker-compose up brings up ten containers, bootstraps a three-node MySQL cluster, imports the schema, and configures MySQL Router, all without manual steps. That level of operational automation is unusual for a project at this scope.
Trade-offs in the design
Several decisions favored simplicity over maximum scalability, and that was intentional.
Most domain logic lives inside a single Flask service. Splitting into many smaller microservices would add operational complexity without providing clear benefits at this project scope. The linguistics service splits off because its responsibilities are genuinely different, not because microservices are philosophically required.
Processing is synchronous throughout. Background queues and message brokers were intentionally skipped. For the current scale, synchronous HTTP calls are simpler to reason about and debug. If you needed to add indexing jobs, notifications, or analytics pipelines, an event broker would become necessary.
Search uses SQL LIKE queries rather than a dedicated search engine. For small datasets this is perfectly sufficient. For a platform with millions of works, it would need replacing with something purpose-built for relevance ranking.
Docker Compose orchestrates the environment. A production system would likely need Kubernetes or a managed database service. But for demonstrating the architecture and proving operational competence, Compose does exactly what’s needed.
What’s coming in this series
This post is the overview. The rest of the series goes deep on each layer.
The next post covers the full architecture: how the services connect, what Docker networks isolate what, and where each request travels from browser to response. After that we look at the React frontend, the Flask API design, the MySQL InnoDB Cluster setup, and finally the complete JWT lifecycle from login through logout.
Each post is self-contained. You can read them in order or jump to the part you care about. The code is real and inspectable throughout.
