Microservice Architecture

Microservices have emerged as a widely adopted architectural style, gaining significant popularity in recent years. This overview highlights the key features that make microservices unique, both in their structure and underlying philosophy.

How Architecture Styles Emerge

Most architectural patterns are identified retrospectively, as architects notice recurring solutions to evolving software challenges. These patterns often become broadly recognized and replicated as they prove effective in addressing common problems. However, microservices differ in this regard—it was named early in its adoption, largely due to a landmark blog post by Martin Fowler and James Lewis in March 2014. Their article articulated the core principles of this new architecture, guiding developers and architects in understanding its approach.

Influence of Domain-Driven Design

Microservices draw heavily from domain-driven design (DDD), a methodology for organizing software around core business domains. One key concept from DDD, bounded context, has significantly shaped the microservices approach.

A bounded context represents a self-contained domain with clearly defined boundaries. Within these boundaries, entities, behaviors, and data are tightly coupled to achieve specific functionality. For instance, a domain like CatalogCheckoutmight include elements such as catalog items, customers, and payments. In a traditional monolithic architecture, these components would often be shared and reused across the application.

Microservices, on the other hand, ensure that each bounded context remains independent. Internal elements like code and database schemas are encapsulated and not shared with other contexts. This independence allows each service to evolve without being constrained by external dependencies.

Trade-offs: Reuse vs. DecouplingTrade-offs: Reuse vs. Decoupling

While reuse in software design can reduce redundancy, it comes with the cost of increased coupling. Shared components often tie systems together, making them harder to change independently. In microservices, the emphasis shifts from reuse to decoupling. By avoiding shared dependencies, microservices maintain flexibility and autonomy across services, even at the expense of some duplication.


Topology

Microservices, being designed for single-purpose functionality, are much smaller in size compared to other distributed architectures. Each microservice is expected to be self-sufficient, containing all the components necessary for its operation.

Distributed Nature

Independent Processes:

  • Each microservice runs in its own process.
  • Example: A Payment service runs in a separate container from an Order service, ensuring independence.

Flexible Deployment:

  • Initially designed for physical machines, now commonly deployed on virtual machines or containers.
  • Example: Docker containers are often used to package and deploy services.

Resource Isolation:

  • Each service has its own resources (CPU, memory, storage).
  • Example: If the Inventory service needs more memory, scaling it does not affect the User service.

Addressing Shared Infrastructure Issues:

  • Avoids problems like resource contention and poor isolation in multitenant setups.
  • Example: In a monolith, a heavy database query in one module might slow down other modules, but microservices prevent such conflicts.

Scalability and Independence:

  • Each service can be scaled individually based on its load.
  • Example: Scale the Search service during high traffic without touching the Checkout service.

Modern Tooling:

  • Leverages open-source OS, automated provisioning, cloud platforms, and containerization.
  • Example: Kubernetes orchestrates containers to manage services efficiently.

Performance Trade-offs:

  • Network calls between services are slower than in-process method calls.
  • Example: A request from the Cart service to the Product service involves a network call, adding latency.

Security Overhead:

  • Each service requires endpoint-level security.
  • Example: Validating an API token for every request between Order and Payment adds processing time.

Avoid Cross-Service Transactions:

  • Transactions across multiple services are discouraged due to complexity.
  • Example: Instead of a single transaction for Order and Payment, use eventual consistency with event-driven mechanisms like Kafka.

Granularity Matters:

  • Carefully decide how small or large a service should be to balance performance and decoupling.
  • Example: Combining User and Profile services might simplify communication if they are tightly related.


Bounded Context in Microservices

A core principle of microservices is bounded context, where each service is designed to represent a specific domain or workflow. This means that every service is self-contained, including all necessary components like classes, submodules, and database schemas required for its functionality.

Self-Containment:

  • Each service operates independently within its domain.
  • Example: An Order service manages its own entities (like Order and Item) and database tables, without relying on shared components from other services.

Avoiding Coupling:

  • Unlike monolithic architectures, where developers often share common classes (e.g., an Address class used across modules), microservices prioritize independence.
  • Duplication is preferred over introducing dependencies between services.

Domain Partitioning:

  • Microservices take domain-partitioning to a granular level, where each service corresponds to a domain or subdomain.
  • Example: A Customer service handles customer data, while a Billing service is responsible for managing payments, with no overlap in their responsibilities.

Inspired by Domain-Driven Design (DDD):

  • Microservices bring the logical concepts of DDD into practice by physically separating domains into distinct services.
  • Example: The concept of bounded context in DDD aligns perfectly with the independent nature of microservices, ensuring each service is focused on a single purpose.


Granularity

Determining the right granularity for microservices can be challenging for architects. A common mistake is making services too small, which leads to the need for excessive communication between services to accomplish a task. The term "microservice" was coined to contrast with the larger, more monolithic service-oriented architecture, but it is often misunderstood. Many developers mistakenly treat it as a rule to create overly fine-grained services, when in fact it’s just a label to describe smaller, more focused services.

The goal of microservices is to define clear service boundaries that reflect specific domains or workflows. In some cases, these boundaries may be larger depending on the business process. Here are some guidelines to help architects define appropriate service boundaries:

  1. Purpose: Each microservice should be focused on a specific, cohesive functionality. It should handle one significant task for the overall application. Example: A User service should handle all user-related activities, such as profile management and authentication.
  2. Transactions: When designing services, it’s helpful to consider the transactions that occur within a domain. If certain entities often need to work together in a transaction, that can help define a service boundary. Avoiding distributed transactions is key to better designs. Example: Combining Order and Payment services might simplify handling transactions.
  3. Choreography: If services are well isolated but require constant communication, it may be better to combine them into a larger service to reduce communication overhead. Example: A Cart service that frequently interacts with a Product service could be merged to reduce constant communication.


Data Isolation in Microservices

Data isolation is an essential principle of microservices, driven by the idea of bounded contexts. Unlike traditional architectures, where multiple services share a single database for persistence, microservices avoid shared schemas and databases as integration points to minimize coupling between services.

When architecting microservices, it’s important to carefully consider data isolation and service boundaries. One common mistake is falling into the “entity trap,” where services are designed to mirror entities in a database, which can lead to tightly coupled systems. For example, if every service is modeled around a Customer entity from the same database, changes to the Customer structure could affect multiple services, causing issues across the system.

In traditional architectures, a single relational database serves as the "source of truth," unifying data. In microservices, however, this is no longer possible because each service manages its own data independently. Architects must decide how to handle this challenge:

  • Source of Truth: One service can act as the source of truth for certain data, and other services can query it when needed. Example: A Product service might manage the source of truth for product details, while other services, like Order or Inventory, can retrieve this data when necessary.
  • Data Replication or Caching: Services can replicate data or use caching mechanisms to share data across the system without direct dependencies on each other. Example: The Shipping service might cache product availability from the Inventory service to avoid frequent database calls.

While data isolation adds complexity, it also brings flexibility. Each service can choose the most appropriate database for its needs, whether it’s a relational database, a NoSQL database, or a cache, without impacting other services. This decoupling allows teams to change their database or other dependencies without affecting others, offering more freedom and agility in development.


Operational Reuse in Microservices

Microservices favor duplication over coupling, which raises challenges for managing common operational concerns like logging, monitoring, and circuit breakers. Instead of coupling these concerns directly to services, architects use patterns like sidecars and service meshes to address them.

  1. The Sidecar Pattern In this approach, each service includes a sidecar component that handles operational tasks. This sidecar can be managed by individual teams or a shared infrastructure team. For example: A sidecar might manage logging for a Payment service. If a logging tool requires an upgrade, only the sidecar needs to be updated, ensuring all services automatically benefit from the new version without affecting their core logic.
  2. Service Mesh A service mesh connects all sidecars to create a unified operational plane across services. This allows consistent logging, monitoring, and control mechanisms across the architecture. Example: A service mesh can provide a dashboard showing logs from Order, Payment, and Shipping services in one place, simplifying troubleshooting and monitoring.


Service Discovery in the API Layer

The API layer often hosts service discovery, enabling elasticity and scalability in microservices. Instead of directly calling a service, requests go through the service discovery mechanism to:

  • Find the optimal instance of a service to handle the request.
  • Spin up new instances as needed to meet scaling demands.

For example, when a Payment service becomes overloaded during a sale, service discovery can automatically route new requests to additional instances, ensuring consistent performance. By integrating service discovery with the API layer, architects provide a centralized point for dynamic service management.


Decoupling in Microservices: User Interface Approach

Microservices emphasize decoupling, ideally extending to user interfaces (UI) as well as backend systems. While the original vision aligned with domain-driven design (DDD), where each bounded context included its UI, practical challenges in web application partitioning often make this difficult. As a result, two common UI approaches emerge in microservices architectures:

1. Monolithic User Interface: In this approach, a single, unified frontend handles all user interactions and communicates with the backend via an API layer.

  • How it works: The frontend acts as a single application, whether it is a web, mobile, or desktop interface. For example, a web application built with a JavaScript framework like Angular or React fetches data from various microservices through a centralized API.
  • Example: A shopping website where the product catalog, cart, and checkout functionalities are all part of the same web interface, even though they interact with separate backend services.

2. Microfrontend Pattern

This approach breaks the UI into smaller, independent components, aligning with the granularity and isolation principles of backend microservices. Each backend service manages its own UI component, which integrates with others at runtime.

  • How it works: Each service emits its own user interface fragment. The frontend stitches these fragments together, creating a cohesive user experience.
  • Example: In an online retail platform:

1. The Product service renders the product details UI.

2. The Cart service renders the shopping cart UI.

3. The Order service renders the order history UI.

The main frontend combines these into a seamless interface for users.

Implementation: Developers can use component-based frameworks like React, Vue.js, or specialized microfrontend tools such as Module Federation in Webpack.

Choosing the Right Approach

The decision between a monolithic UI and microfrontends depends on the team's expertise, application complexity, and the need for decoupling.

  • Use a monolithic UI for simpler applications or when rapid development is needed.
  • Opt for microfrontends in large-scale, distributed applications where independent team workflows and fault isolation are critical.


Communication in Microservices

Effective communication is crucial in microservices, as it balances service decoupling with the need for coordination. The choice between synchronous and asynchronous communication significantly impacts service interactions.

1. Synchronous Communication: Synchronous communication requires the caller to wait for a response, often using protocols like REST. For example, in an e-commerce application, the Cart service might call the Inventory service to check product availability during checkout. However, architects must ensure services are resilient and avoid tight coupling.

2. Protocol Awareness: Without a centralized integration hub, each service must understand how to interact with others. For instance, services might agree to use REST for querying and message queues for event-driven communication, ensuring consistency.

3. Heterogeneous Interoperability: Microservices support a polyglot environment, enabling teams to use different technology stacks for different services. For example:

  • The Product service might be implemented in Java.
  • The Cart service might use Node.js.
  • The Notification service might be built in Python.

4. Enforced Heterogeneity: To avoid unintended coupling, some organizations intentionally enforce diverse technology stacks across teams. For instance, one team may use Java, while another uses .NET. This ensures that shared logic or data models cannot inadvertently create dependencies, preserving service independence.

5. Asynchronous Communication: In asynchronous communication, services exchange events or messages without waiting for immediate responses. This is common in event-driven architectures:

  • A Payment service might emit an event when a payment is processed.
  • The Order service listens to the event and updates the order status.

Patterns like choreography (services reacting to events) and orchestration (a central coordinator managing workflows) ensure smooth coordination.

Example: In a ride-hailing platform:

  1. The Driver service emits an event when a driver is available.
  2. The Ride Matching service asynchronously consumes this event and assigns the driver to a rider.


Choreography and Orchestration in Microservices

Microservices rely on choreography and orchestration to manage interactions between services. Both approaches have unique characteristics, use cases, and trade-offs, and architects must choose based on the problem's complexity and domain requirements.

Choreography: Decoupled Communication


In choreography, services interact directly with each other without a central coordinator. Each service reacts to events and performs its tasks independently, respecting the principle of bounded contexts.

Example: Imagine a Wishlist service in an e-commerce platform:

  • A user requests their wishlist details.
  • The Wishlist service needs additional information from the CustomerDemographics service to complete the response.
  • The Wishlist service directly fetches the data and returns the combined result.

Advantages:

  • Promotes high decoupling.
  • Aligns with microservices' philosophy of distributed, autonomous services.
  • Simplifies scaling and fault isolation.

Challenges:

  • Complex workflows are harder to coordinate.
  • Error handling and retries can be difficult to manage across distributed services.


Orchestration: Centralized Coordination

In orchestration, a dedicated service (the mediator) manages the workflow by coordinating calls to other services. This approach centralizes control, simplifying complex workflows at the cost of some coupling.

Example: For generating a Customer Report, a ReportGenerator service could:

  • Fetch demographics from the CustomerDemographics service.
  • Fetch order history from the OrderHistory service.
  • Aggregate the data and send the final report to the user.

Advantages:

  • Simplifies complex workflows by concentrating logic in one place.
  • Centralized error handling and retry mechanisms.
  • Easier to track and audit processes.

Challenges: Introduces coupling, as multiple services rely on the orchestrator. May become a single point of failure or a performance bottleneck.


Choosing Between Choreography and Orchestration

1. Choreography is ideal for:

  • Simple workflows with limited service interactions.
  • Scenarios where service autonomy is critical.
  • Decentralized event-driven architectures.

2. Orchestration works better for:

  • Complex workflows requiring coordination across multiple services.
  • Scenarios with centralized business logic.
  • Workflows that need consistent error handling and monitoring.

Practical Example: Order Processing in E-commerce

1. Choreography:

  • Order Service emits an "Order Created" event.
  • Payment Service, Inventory Service, and Notification Service independently react to the event and perform their tasks (e.g., charge payment, reserve inventory, send email).

2. Orchestration: An Order Orchestrator manages the workflow:

  • Calls the Payment Service to process the payment.
  • Calls the Inventory Service to reserve stock.
  • Calls the Notification Service to send an email.
  • Handles failures centrally by retrying or rolling back as needed.

Both approaches have trade-offs:

  • Choreography emphasizes autonomy and decoupling but complicates coordination.
  • Orchestration simplifies complex workflows but introduces some coupling.


Transactions and Sagas in Microservices

In microservices, achieving extreme decoupling is a key goal. However, transactional coordination across distributed services introduces significant challenges. In monolithic applications, atomic transactions were straightforward due to centralized databases. Microservices, by design, decentralize databases, making traditional transactions difficult to implement and often undesirable.

Why Avoid Distributed Transactions?

1. Violation of Decoupling Principles: Distributed transactions create tight coupling between services, contradicting the microservices philosophy.

2. Indicators of Over-Granularity: If transactional coordination is frequently needed across services, it may signal that the services are too granular. Fixing service boundaries and grouping logically cohesive operations into a single service often eliminates this need.

3. Dynamic Connascence: Distributed transactions can create a strong dependency on values across services, increasing complexity and fragility.

When Transactions Are Unavoidable

In rare cases, distinct services with separate boundaries and architectural characteristics may still require transactional coordination. For these scenarios, patterns like the Saga Pattern provide a solution, albeit with trade-offs.

The Saga Pattern

The Saga Pattern breaks a transaction into smaller, independent steps executed by different services. A saga coordinator orchestrates these steps, ensuring consistency across services through compensating actions in case of failures.

1. Forward Transactions: Each service executes its part of the transaction, and the coordinator records success or failure.

2. Compensating Transactions: If one step fails, the coordinator triggers compensating actions (undo operations) in previously successful steps to maintain consistency.

Examples

1. Order Processing in E-commerce:

Scenario: Placing an order involves reserving inventory, processing payment, and notifying the user.

Saga Steps:

1. Inventory Service reserves stock.

2. Payment Service charges the customer.

3. Notification Service sends a confirmation.

Compensation: If payment fails, the coordinator instructs the Inventory Service to release the stock.


2. Flight Booking:

Scenario: Booking a flight involves reserving a seat and charging the customer.

Saga Steps:

1. Reserve the seat in the Flight Service.

2. Charge the customer via the Payment Service.

Compensation: If seat reservation succeeds but payment fails, the Flight Service cancels the seat.


Challenges with Sagas

1. Increased Complexity:

  • Coordinating compensating actions adds complexity to the design and implementation.
  • Managing pending states and asynchronous requests can become difficult, especially with multiple interdependent transactions.

2. Network Overhead: Compensating transactions increase traffic and latency, as each service must communicate extensively with the coordinator.

3. Implementation Effort: Each transactional step requires a corresponding undo operation, often doubling the development and debugging workload.

Best Practices

1. Use Sagas Sparingly: Rely on the saga pattern only when transactional behavior across services is essential and cannot be avoided.

2. Refactor Granularity First: If distributed transactions dominate your architecture, reconsider service boundaries. Consolidating services may simplify workflows and reduce transactional needs.

3. Focus on Localized Transactions: Keep transactions within a single service whenever possible to maintain simplicity and performance.


Reference: Fundamentals of Software Architecture by Mark Richards & Neal Ford (ISBN: 978-1-492-04345-4)


要查看或添加评论,请登录

Saurabh Kumar的更多文章

社区洞察

其他会员也浏览了