
In the context of interacting services modules, the inevitable question emerges: By what rules does communication take place? In IT products, a “contract” represents a formal understanding of what data flows between systems and how it is transmitted. This entails the data format (JSON, Protobuf, etc.), structural elements (fields, data types), communication protocol (REST, gRPC, message queues), and other specifications.
A contract ensures openness (everyone knows what is received and sent), predictability (we can update the contract and maintain versions), and reliability (our system will not fail if we make well-managed changes).
In practice, although everyone talks about microservices, “contracts” and APIs, we often see people adopt the approach: “Why not create a shared table in the database instead of building APIs?”
Therefore, while using a shared table for data exchange may seem efficient and optimized for quick results, it generates various technical and organizational challenges in the long run. However, when teams choose shared tables for data exchange, they may face numerous problems during implementation.
When services communicate via REST/gRPC/GraphQL, they have a formal definition: OpenAPI (Swagger), protobuf schemas, or GraphQL schemas. These define in detail which resources (endpoints) are available, which fields are expected, their types, and the request/response formats. When ‘a shared table’ acts as a contract there isn’t a formal description: There is no formal description of the contract; only the table schema (DDL) is available and even that is not well documented. Any minor modification of the table structure (e.g., adding or deleting a column, changing data types) can affect other teams that read from or write to this table.
API versioning is a normal practice: We might have v1, v2, and so on, and we can keep backward compatibility and then gradually move clients to the newer versions. For database tables, we only have DDL operations (e.g., ALTER TABLE
), which are tightly coupled to a specific DB engine and require careful handling of migrations.
There is no centralized system that can send alerts to consumers about schema changes that require them to update their queries. As a result, “under-the-table” deals may occur: Someone can post in a chat, “Tomorrow, we’re changing column X to Y”, but there’s no guarantee everyone will be ready in time.
When there’s a clearly defined API, it’s evident who owns it: the service that serves as the API publisher. When multiple teams use the same database table there is confusion about who gets to determine the structure and which fields to store and how to interpret them. As a result, the table can become “no one’s property,” and every change becomes a quest: “We need to check with that other team in case they’re using the old column!”
It’s hard to keep track of who can read and write to a table if many teams have access to the DB. There is a chance that unauthorized services can access the data even though it was not intended for them. It’s easier to manage such issues with an API: You can control the access rights (who can call which methods), use authentication and authorization, and monitor who called what. With a table, it’s much more complicated.
Any internal modifications to the data (reorganizing indexes, partitioning the table, changing the DB) become a global problem. If the table functions as a public interface, the owner cannot make internal changes without endangering all external readers and writers.
This is the most painful aspect: How does one go about informing another team that the schema will be changing the next day?
When multiple teams use a shared table to select and update critical data, it can easily become a “battlefield.” The result is that business logic ends up scattered across different services, and there is no centralized control of data integrity. It becomes very difficult to know why a particular field is stored in a particular way, who can update it, and what happens if it is left blank.
For example, suppose the table breaks: Let’s say, there is bad data or someone has taken a lock on some crucial rows. Identifying the source of the problem can often require asking every team with DB access to determine what query caused the problem. It’s often not obvious: This means one team’s query might have locked up the database, while another team’s query produces the observable error.
A shared database is a single point of failure. If it goes down, then many services will go down with it. When the database has problems with performance because of one service’s heavy queries, all services experience problems. In a model with clear-cut APIs and data ownership, every team is masters of their service’s availability and performance, so a failure in one component does not propagate to others.
A common compromise is: “We’ll give you a read-only replica so you can query without affecting our main database.” At first, that might address some load problems, but:
Modern design practices (for example, “API First” or “Contract First”) start with a formal interface definition. OpenAPI/Swagger, protobuf, or GraphQL schemas are used. This way, both people and machines know which endpoints are available, which fields are required, and what data types are used.
In a microservices (or even modular) architecture, the assumption is that each service owns its data entirely. It defines the structure, storage, and business logic and provides an API for all external access to that API. Nobody can touch ‘someone else’s’ database: only official endpoints or events. This makes life easier whenever changes are in question and it is always clear who is to blame.
GET /items
, POST /items
, etc., and clients make requests with a well-defined data schema (DTO).
No matter what model, it is both possible and essential to implement version control on the interface. For example:
A fundamental principle is that the team that owns the data gets to decide how to store and manage it, but they should not give direct write access to other services. Others must go through the API as opposed to editing foreign data. This yields clearer responsibility distribution: If service A is broken, then it is service A’s responsibility to fix it and not its neighbors.
At first glance, if everything is in one team, why complicate things with an API? In reality, even if you have a single product split into modules, a shared table can lead to the same issues.
For example, the Orders service is the owner of the orders table, and the Billing service does not access that table directly – it makes calls to the Orders service’s endpoints to get order details or to mark an order as paid.
At a higher level, when two or more teams are responsible for different areas, the principles remain the same. For instance:
If Team B directly queries the “Catalog” table belonging to Team A, any internal schema changes at A (e.g., adding fields, altering structure) may affect Team B.
The proper approach is to use an API: Team A provides endpoints like GET /catalog/items
, GET /catalog/items/{id}
, etc., and Team B uses those methods. If A is able to support older and newer versions, they can release /v2, which gives B time to migrate.
With a formal contract, all changes are visible: in Swagger/OpenAPI, .proto files, or event documentation. Any update can be discussed beforehand, properly tested, and scheduled, with backward compatibility strategies as needed.
Changes in one service have less impact on others. The team does not have to worry about “breaking” someone else if they properly manage new and old fields or endpoints, ensuring a smooth transition.
API gateways, authentication, and authorization (JWT, OAuth) are standard for services, but nearly impossible with a shared table. It’s easier to fine-tune access (who can call which methods), keep logs, track usage statistics, and impose quotas. This makes the system safer and more predictable.
A shared table in the database is an implementation detail rather than an agreement between services thus not considered a contract. The many issues (complex versioning, chaotic changes, unclear ownership, security, and performance risks) make this approach untenable in the long run.
The correct approach is Contract First which means defining interaction through formal design and following the principle that each service remains the owner of its data. This not only helps to decrease technical debt but also increases transparency, speeds up product development, and enables safe changes without having to engage in firefighting over database schemas.
It is both a technical issue (how to design and integrate) and an organizational issue (how teams communicate and manage changes). If you want your product to grow without having to deal with endless emergencies regarding database schemas, then you should start thinking in terms of contracts rather than direct database access.