Building a Real-Time Pub Sub Messaging System with SQLite and .NET: A Complete Developer's Guide
- Bryan Downing
- 12 minutes ago
- 12 min read
Introduction
In the ever-evolving landscape of software development, the ability to facilitate seamless communication between different components of an application remains a fundamental requirement. Message-driven architectures have emerged as a powerful paradigm for building scalable, loosely coupled systems that can handle complex workflows while maintaining simplicity and reliability. Among the various messaging patterns available to developers, the Publisher-Subscriber pattern stands out as one of the most versatile and widely adopted approaches for enabling asynchronous communication between software components.

This comprehensive guide documents the complete journey of building a functional Pub/Sub messaging system using SQLite as the message broker and .NET as the development framework. Through a series of practical terminal sessions, we explore the challenges, solutions, and best practices that emerge when implementing such a system from scratch. The project demonstrates how even lightweight database solutions like SQLite can serve as effective message brokers for small to medium-scale applications, providing developers with a simple yet powerful alternative to heavyweight messaging infrastructure.
The Pub/Sub pattern fundamentally decouples message producers from message consumers, allowing publishers to broadcast messages without knowledge of which subscribers, if any, will receive them. This architectural approach offers numerous benefits including improved scalability, enhanced flexibility, and simplified system maintenance. By implementing this pattern with SQLite, we create a solution that is portable, requires no additional infrastructure, and can be easily integrated into existing applications.
Understanding the Publisher-Subscriber Pattern
The Publisher-Subscriber pattern, commonly abbreviated as Pub/Sub, represents a messaging paradigm where senders of messages, called publishers, do not program the messages to be sent directly to specific receivers, called subscribers. Instead, published messages are characterized into topics without knowledge of what subscribers, if any, there may be. Similarly, subscribers express interest in one or more topics and only receive messages that are of interest, without knowledge of what publishers, if any, there are.
This pattern provides several architectural advantages that make it particularly suitable for distributed systems and microservices architectures. The decoupling between publishers and subscribers means that either side can be modified, scaled, or replaced independently without affecting the other. Publishers can continue sending messages even when no subscribers are available, and new subscribers can be added to the system without requiring any changes to existing publishers.
In traditional implementations, the Pub/Sub pattern relies on a message broker that sits between publishers and subscribers. This broker is responsible for receiving messages from publishers, storing them temporarily, and delivering them to interested subscribers. Popular message brokers include Apache Kafka, RabbitMQ, and Redis, each offering different trade-offs in terms of performance, durability, and complexity. However, for smaller applications or development environments, these solutions may introduce unnecessary complexity and operational overhead.
SQLite presents an interesting alternative for implementing a lightweight message broker. As an embedded database that requires no separate server process, SQLite can be easily bundled with applications and provides ACID-compliant transactions that ensure message integrity. While it may not match the throughput of dedicated message brokers for high-volume scenarios, SQLite offers simplicity, portability, and zero configuration that make it ideal for development, testing, and small-scale production deployments.
Project Architecture and Design Decisions
The messaging system described in this guide consists of two primary components: a Publisher application and a Subscriber application. Both components interact with a shared SQLite database that serves as the message store and broker. The architecture follows a polling-based approach where the subscriber periodically queries the database for new messages rather than receiving push notifications.
The Publisher application provides a command-line interface that allows users to enter messages in a simple format consisting of a topic and a payload. When a message is published, the application inserts a new record into the messages table with the topic, payload, current timestamp, and a consumed flag set to false. This approach ensures that all published messages are durably stored and available for consumption by subscribers.
The Subscriber application implements a continuous polling loop that queries the database for unconsumed messages at configurable intervals. When new messages are found, the subscriber processes them, displays them to the user, and marks them as consumed in the database. This consumed flag prevents messages from being delivered multiple times to the same subscriber while allowing multiple subscribers to receive the same message if desired.
The database schema is intentionally simple, consisting of a single messages table with columns for the message identifier, topic, payload, timestamp, and consumed status. An index on the topic and consumed columns ensures efficient querying even as the message volume grows. This straightforward design makes the system easy to understand, debug, and extend while providing adequate performance for most use cases.
Implementation Details and Code Walkthrough
The implementation begins with the Publisher application, which establishes a connection to the SQLite database and initializes the schema if it does not already exist. The schema initialization is idempotent, meaning it can be safely executed multiple times without causing errors or data loss. This approach simplifies deployment and ensures that the database is always in a consistent state.
The Publisher's main loop reads input from the user, parses it into topic and payload components, and inserts the resulting message into the database. Error handling ensures that malformed input is rejected gracefully with helpful error messages. The application also provides feedback for each published message, confirming the topic and payload that were stored.
The Subscriber application presents more complexity due to its need to continuously monitor the database for new messages. Upon startup, the subscriber verifies that the database file exists and that the expected schema is present. These checks prevent confusing error messages that might otherwise occur when the subscriber attempts to query a non-existent or improperly initialized database.
The polling loop uses asynchronous programming patterns to avoid blocking the main thread while waiting between poll intervals. This approach ensures that the application remains responsive and can handle cancellation requests promptly. The subscriber also tracks statistics such as the total number of messages received, providing useful feedback to the user.
The Database Path Challenge
One of the most significant challenges encountered during development involved the database path used by the Publisher and Subscriber applications. When both applications were run from their respective build output directories, each would use a relative path to the database file, resulting in two separate database files being created in different locations.
This issue manifested as the Subscriber reporting that the messages table did not exist, even though the Publisher had successfully created the database and was publishing messages. The error message indicated an SQLite error with the text "no such table: messages," which was initially confusing because the Publisher had clearly initialized the schema.
Investigation revealed that the Publisher was creating its database at the path Publisher\bin\Release\net10.0\pubsub.db while the Subscriber was looking for its database at Subscriber\bin\Release\net10.0\pubsub.db. These two paths pointed to completely different files, meaning the Subscriber was attempting to read from an empty or non-existent database.
This situation illustrates a common pitfall when developing multi-process applications that share data through file-based storage. Unlike network-based databases that use a central server, file-based databases require careful coordination of file paths to ensure all processes access the same data. The solution requires either using absolute paths or implementing logic to locate a shared database file.
Implementing Robust Database Path Resolution
The solution to the database path challenge involved implementing a sophisticated path resolution mechanism in the Subscriber application. Rather than simply using a relative path, the updated implementation searches for the database file in multiple locations, including the current directory, the solution root, the user's home directory, and the Publisher's output directory.
This search-based approach provides flexibility and convenience for developers. When running both applications from a development environment, the Subscriber can automatically locate the database created by the Publisher without requiring explicit configuration. For production deployments, administrators can specify an explicit path using command-line arguments.
The implementation also includes comprehensive validation that runs before the main processing loop. The application first checks whether the database file exists at the resolved path, providing a clear error message if it does not. Next, it verifies that the messages table exists within the database, ensuring that the schema has been properly initialized. These checks prevent cryptic error messages and guide users toward the correct solution.
Command-line argument parsing was added to allow users to override the default database path using the --db flag. Additional options include the ability to subscribe to a specific topic using --topic and to adjust the polling interval using --interval. A --help option provides documentation for all available options, making the application self-documenting and user-friendly.
Running the System Across Multiple Terminals
Operating the Pub/Sub system requires running the Publisher and Subscriber in separate terminal sessions. This setup mirrors real-world scenarios where publishers and subscribers might run on different machines or in different containers. The terminal-based interface provides immediate feedback and makes it easy to observe the flow of messages through the system.
To start the Publisher, developers navigate to the Publisher's output directory and execute the application. The Publisher immediately connects to the database, initializes the schema if necessary, and presents a prompt for entering messages. Each message consists of a topic and a payload separated by whitespace, with the topic being a single word and the payload being the remainder of the input.
Starting the Subscriber requires specifying the path to the database created by the Publisher. This is accomplished using the --db command-line argument followed by the absolute path to the database file. Once started, the Subscriber displays its configuration and begins polling for new messages. Any messages published while the Subscriber is running will be received and displayed within the polling interval.
The system can be tested by entering messages in the Publisher terminal and observing their arrival in the Subscriber terminal. The Subscriber displays detailed information about each received message, including the message identifier, topic, timestamp, and payload. After processing, messages are marked as consumed and will not be delivered again.
Best Practices for Shared Database Locations
While the automatic path resolution provides convenience during development, production deployments benefit from establishing a consistent shared location for the database file. Creating a dedicated data directory within the project structure or using a well-known system location ensures that all components can reliably locate the database without complex path searching.
One recommended approach involves creating a data directory at the solution root and configuring both applications to use this location by default. This keeps the database file separate from build artifacts, making it easier to manage and backup. The database file persists across builds and can be easily located for debugging or manual inspection.
For deployment scenarios where the Publisher and Subscriber run on different machines, the SQLite database must be replaced with a network-accessible alternative. While SQLite does support network file systems, concurrent access from multiple machines can lead to corruption or performance issues. In such cases, migrating to a client-server database like PostgreSQL or MySQL, or adopting a dedicated message broker, becomes necessary.
Configuration management also becomes important as the system grows. Rather than relying on command-line arguments for every execution, applications can read configuration from environment variables or configuration files. This approach simplifies deployment automation and allows different configurations for development, testing, and production environments.
Extending the System for Production Use
The basic Pub/Sub system demonstrated in this guide provides a foundation that can be extended in numerous ways to meet production requirements. One common enhancement involves adding support for multiple subscribers, each tracking their own consumption progress. This can be implemented by replacing the simple consumed flag with a separate consumption tracking table that records which subscribers have received which messages.
Message retention policies represent another important consideration for production systems. Without cleanup, the messages table will grow indefinitely, eventually consuming significant disk space and degrading query performance. Implementing a retention policy that deletes or archives old messages keeps the database manageable while preserving the ability to review historical messages when needed.
Security considerations become paramount when deploying the system in production environments. While SQLite does not provide built-in authentication or encryption, these features can be implemented at the application level. Encrypting message payloads before storage protects sensitive data, while implementing access controls in the Publisher and Subscriber applications prevents unauthorized use.
Monitoring and observability features help operators understand system behavior and diagnose issues. Adding logging throughout the application code creates an audit trail of all operations. Exposing metrics such as message throughput, processing latency, and queue depth enables proactive monitoring and capacity planning.
Performance Considerations and Optimization
SQLite performs remarkably well for many use cases, but understanding its performance characteristics helps developers make informed decisions about when it is appropriate and how to optimize its use. Write operations in SQLite are serialized, meaning only one process can write to the database at a time. This limitation becomes significant in high-throughput scenarios where many publishers are competing to insert messages.
The polling-based architecture of the Subscriber introduces latency between message publication and delivery. This latency equals the polling interval in the worst case, which may be unacceptable for real-time applications. Reducing the polling interval improves responsiveness but increases database load and CPU utilization. Finding the right balance requires understanding the application's latency requirements and resource constraints.
Database indexing plays a crucial role in query performance, particularly as the message volume grows. The index on topic and consumed columns enables efficient filtering when subscribers are interested in specific topics. Additional indexes might be beneficial depending on query patterns, but each index adds overhead to write operations.
Connection pooling and prepared statements can improve performance in high-throughput scenarios. Rather than opening a new connection for each operation, maintaining a pool of connections reduces overhead. Prepared statements allow the database engine to reuse query plans, avoiding the cost of query parsing and optimization for repeated operations.
Error Handling and Resilience
Robust error handling ensures that the system behaves predictably in the face of failures and provides useful feedback to users and operators. The implementations demonstrated in this guide include error handling for common failure scenarios such as missing database files, schema initialization failures, and query execution errors.
Network and file system errors can occur unexpectedly, particularly in production environments. Implementing retry logic with exponential backoff helps applications recover from transient failures without overwhelming the system. Circuit breaker patterns prevent cascading failures by temporarily stopping operations when error rates exceed thresholds.
Transaction management ensures data consistency even when errors occur during complex operations. SQLite's support for ACID transactions means that operations either complete fully or have no effect, preventing partial updates that could corrupt data. The implementations use transactions appropriately to maintain consistency.
Graceful shutdown handling ensures that applications clean up resources properly when terminated. The Subscriber implementation demonstrates handling of the Ctrl+C signal, allowing the polling loop to complete its current iteration before exiting. This approach prevents data corruption and ensures that all received messages are properly acknowledged.
Comparing SQLite to Dedicated Message Brokers
Understanding when SQLite is appropriate and when dedicated message brokers are needed helps developers choose the right tool for their requirements. SQLite excels in scenarios where simplicity, portability, and ease of deployment are priorities. It requires no additional infrastructure, works on any platform that supports .NET, and can be bundled directly with applications.
Dedicated message brokers like RabbitMQ, Apache Kafka, and Redis offer capabilities that SQLite cannot match. These include push-based message delivery for lower latency, horizontal scaling across multiple nodes for higher throughput, and advanced features like message routing, dead letter queues, and exactly-once delivery semantics. For applications requiring these capabilities, the additional operational complexity is justified.
The choice between SQLite and dedicated message brokers often depends on the development phase and scale of the application. Starting with SQLite allows teams to validate their architecture and implement business logic without the distraction of infrastructure complexity. As the application matures and requirements become clearer, migrating to a dedicated message broker can be undertaken with confidence.
Hybrid approaches are also possible, using SQLite for development and testing while deploying with a dedicated message broker in production. Abstracting the message broker behind an interface enables this flexibility, allowing the implementation to be swapped without changing application code. This pattern supports both rapid development and production-ready deployments.
Lessons Learned and Key Takeaways
The development journey documented in this guide reveals several important lessons applicable to a wide range of software projects. First, seemingly simple requirements can hide subtle complexities that only emerge during implementation and testing. The database path issue was not anticipated during design but required significant changes to resolve properly.
Second, clear error messages dramatically improve the developer and user experience. The enhanced Subscriber implementation provides specific guidance when problems occur, reducing frustration and time spent debugging. Investing in error handling and user feedback pays dividends throughout the application lifecycle.
Third, command-line interfaces benefit from self-documenting features like help options and usage examples. Users who encounter the application for the first time can quickly understand how to use it without consulting external documentation. This accessibility encourages adoption and reduces support burden.
Fourth, testing multi-process applications requires attention to shared state and resource management. The interaction between Publisher and Subscriber through the shared database file introduces coupling that must be carefully managed. Clear documentation of setup procedures and common pitfalls helps users successfully deploy and operate the system.
Conclusion
Building a Pub/Sub messaging system with SQLite and .NET demonstrates that powerful architectural patterns can be implemented with simple, accessible technologies. The resulting system provides a functional message broker suitable for development, testing, and small-scale production deployments. Through the challenges encountered and solutions developed, this project illustrates important principles of software design including error handling, configuration management, and user experience.
The Publisher-Subscriber pattern remains one of the most valuable tools in a software architect's toolkit, enabling loose coupling and asynchronous communication that improve system flexibility and scalability. While this implementation uses SQLite as the message store, the patterns and practices demonstrated apply equally to implementations using dedicated message brokers or other storage technologies.
Developers embarking on similar projects can use this guide as a reference for both the technical implementation and the problem-solving process. The challenges documented here are common in software development, and the solutions demonstrate approaches that can be adapted to many different contexts. By understanding both what to build and how to overcome obstacles along the way, developers can create robust, user-friendly applications that meet their requirements.
The complete source code for both the Publisher and Subscriber applications provides a starting point for experimentation and extension. Whether used as a learning exercise, a development tool, or a foundation for production systems, this Pub/Sub implementation demonstrates the power of combining proven architectural patterns with practical, accessible technologies. The journey from initial implementation through debugging and refinement reflects the reality of software development, where success comes not just from writing code but from understanding systems holistically and solving problems systematically.


Comments