Photo by ThisisEngineering RAEng on Unsplash
Examination Preparation Materials - Chapter 04
Engineering Practices for Fintech at Scale
Multi-Tenant Systems
Definition: A multi-tenant system is a software architecture where a single instance of an application serves multiple independent users or organizations (tenants). It's like an apartment building where tenants share the same infrastructure (building) but have private spaces (apartments) with their own data and configurations.
Database Schema with Tenant Isolation
Leverage a database schema designed to isolate tenant data. This can be achieved through:
Tenant-specific columns or tables: Each tenant has its own data columns or tables, preventing data mixing.
Multi-tenancy frameworks: Frameworks like Django's Tenant model or Ruby on Rails' multi-tenancy plugins automate tenant isolation within the database schema.
Configuration Management
Store tenant-specific configurations (settings, preferences) separately. This could involve:
Configuration databases: Maintain a separate database or table for each tenant's configurations.
Key-value stores: Utilize key-value stores like Redis or AWS DynamoDB to store tenant configurations as key-value pairs.
Design Patterns
In the realm of software design, a design pattern is a well-established, proven solution that acts as a reusable template to tackle frequently encountered programming challenges. It's not a piece of code you directly copy and paste, but rather a conceptual blueprint that outlines the structure and relationships between classes or objects to achieve a specific functionality.
Think of it like a recipe in a cookbook. The recipe provides a general framework for creating a dish, but you can adjust the ingredients and quantities to suit your personal preferences. Similarly, a design pattern outlines the essential steps and components for solving a particular design problem, but you can adapt it to the specific context of your software application.
If i have to say in one sentence - Design patterns provide predictability to the code segments. - Dhruba Adhikari, CTO (Khalti)
Singleton Pattern
class Singleton(object):
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
return cls._instance
def __init__(self, data=None):
# Optionally, you can add initialization logic here
# if data is not None:
# self.data = data
pass
# Add your class methods here
# Usage example
singleton_instance1 = Singleton()
singleton_instance2 = Singleton()
# Both singleton_instance1 and singleton_instance2 will
# refer to the same object
# You can optionally add data to the Singleton instance
# during initialization
# singleton_instance3 = Singleton(data="Some data")
Ensures a class has only one instance and provides a global access point to it.
Use case: Managing a system-wide logger or configuration manager.
Factory Pattern
# Interface for the product being created
class Product:
def do_something(self):
raise NotImplementedError("Subclasses must implement do_something")
# Concrete product implementations
class ProductA(Product):
def do_something(self):
print("ProductA does something")
class ProductB(Product):
def do_something(self):
print("ProductB does something")
# Factory class to create products
class ProductFactory:
@staticmethod
def create_product(product_type):
if product_type == "A":
return ProductA()
elif product_type == "B":
return ProductB()
else:
raise ValueError("Invalid product type")
# Usage example
product = ProductFactory.create_product("A")
product.do_something() # Output: ProductA does something
Creates objects without specifying the exact class to be instantiated.
Use case: Generating different types of financial transactions based on user input.
Observer Pattern
# Interface for the subject (observable)
class Subject:
def __init__(self):
self._observers = []
def register_observer(self, observer):
self._observers.append(observer)
def unregister_observer(self, observer):
self._observers.remove(observer)
def notify_observers(self):
for observer in self._observers:
observer.update(self)
# Interface for the observer
class Observer:
def update(self, subject):
raise NotImplementedError("Subclasses must implement update")
# Concrete subject implementation
class ConcreteSubject(Subject):
def do_something(self):
# ... change state ...
self.notify_observers()
# Concrete observer implementation
class ConcreteObserver(Observer):
def update(self, subject):
print(f"Observer notified: {subject.__class__.__name__}")
# Usage example
subject = ConcreteSubject()
observer1 = ConcreteObserver()
observer2 = ConcreteObserver()
subject.register_observer(observer1)
subject.register_observer(observer2)
subject.do_something() # Output: Observer notified: ConcreteSubject
Defines a one-to-many dependency between objects, where changes to one object (subject) notify and update all dependent objects (observers).
Use case: Alerting fraud detection modules or notifying interested parties about account activity.
Django Signals are implementation of the Observer pattern.
Adapter Pattern
Scenario: You have a library that can calculate the area of different shapes (square, circle) but expects specific functions for each shape. You want to use a different library that calculates area using a single calculate_area
function with a shape object as input.
# Existing library functions (incompatible interfaces)
def calculate_square_area(side_length):
return side_length * side_length
def calculate_circle_area(radius):
return 3.14 * radius * radius
# Shape class for the new library (compatible interface)
class Shape:
def __init__(self, type):
self.type = type
# Adapter class (bridge between interfaces)
class ShapeAreaAdapter(Shape):
def __init__(self, type, side_length=None, radius=None):
super().__init__(type)
self.side_length = side_length
self.radius = radius
# Translate request to compatible format
def calculate_area(self):
if self.type == "square":
return calculate_square_area(self.side_length)
elif self.type == "circle":
return calculate_circle_area(self.radius)
else:
raise ValueError("Unsupported shape type")
# Usage example
square_adapter = ShapeAreaAdapter("square", side_length=5)
circle_adapter = ShapeAreaAdapter("circle", radius=3)
square_area = square_adapter.calculate_area()
circle_area = circle_adapter.calculate_area()
print(f"Square area: {square_area}") # Output: Square area: 25
print(f"Circle area: {circle_area}") # Output: Circle area: 28.26
Allows incompatible interfaces to work together by wrapping an existing class with an interface the client expects.
Use case: Integrating with legacy financial systems that use different communication protocols.
Imagine you have a music player that only plays MP3 files, but you want to play a WAV file. The adapter pattern is like using a converter to change the WAV file into an MP3 format that the player can understand.
Encryption Understanding
Encryption plays a crucial role in safeguarding sensitive information in fintech applications. Understanding the two main types of encryption, symmetric and asymmetric, is essential for making informed decisions about data security.
Symmetric Encryption
Symmetric encryption uses a single, shared secret key for both encryption and decryption. It's like a key to a locked door; the same key unlocks and locks the door.
Characteristics:
Fast and Efficient: Symmetric encryption is faster than asymmetric encryption due to simpler algorithms.
Key Management: The biggest challenge is securely managing the shared secret key. Any unauthorized access to the key compromises all encrypted data.
Use Cases:
Securing Data at Rest: Symmetric encryption is ideal for encrypting data stored on databases or local devices, ensuring confidentiality.
Secure Communication Channels: HTTPS (Secure Hypertext Transfer Protocol) often utilizes symmetric encryption within a secure socket layer (SSL/TLS) for secure communication between a web server and browser.
Data Encryption in Transit: Symmetric encryption can be used to encrypt data during transmission between authorized parties, like secure file transfer protocols (SFTP).
Asymmetric Encryption
Asymmetric encryption employs a pair of mathematically linked keys: a public key and a private key. The public key is widely distributed, while the private key is kept secret. Data encrypted with the public key can only be decrypted with the corresponding private key.
Characteristics:
Security through Key Separation: Public and private keys being separate strengthens security. Even if the public key is compromised, attackers cannot decrypt data without the private key.
Slower Performance: Asymmetric encryption involves complex algorithms, making it slower than symmetric encryption.
Use Cases:
Digital Signatures: Public-key cryptography forms the basis for digital signatures. A document can be signed with a private key, and anyone can verify its authenticity using the corresponding public key. This ensures data integrity and non-repudiation.
Secure Key Exchange: Sending a symmetric key securely can be done using asymmetric encryption. The recipient uses their private key to decrypt the symmetric key, which is then used for bulk data encryption/decryption.
Secure Logins: Some secure login systems leverage asymmetric encryption. Passwords are not stored directly; instead, they are used to generate a temporary key that unlocks a session key for secure communication.
Combined Use Case:
A common scenario in fintech applications combines both symmetric and asymmetric encryption:
A mobile banking app needs to securely transmit user credentials (e.g., username, password) to the bank server.
The app uses symmetric encryption (e.g., AES) to encrypt the credentials using a randomly generated session key.
The app establishes a secure connection with the server using HTTPS (which uses an asymmetric key exchange between the server and user's device).
The app sends the encrypted session key (using the server's public key) and the encrypted credentials to the server.
The server decrypts the session key using its private key.
The server uses the now-known session key to decrypt the user credentials using symmetric decryption.
This approach provides a layered security mechanism:
Symmetric encryption ensures fast and efficient encryption of sensitive data in transit.
Asymmetric encryption facilitates secure key exchange without revealing the private key, strengthening overall security.
Scaling applications
When your fintech application experiences increased user traffic or data processing demands, you'll need to consider scaling strategies to maintain performance and functionality. Here's a breakdown of two common approaches:
Horizontal Scaling (Scale Out)
This approach involves adding more machines (servers) to your existing infrastructure. You distribute the workload across these additional servers, achieving scalability by leveraging parallel processing power.
Analogy : Add one more Boiler to make more momos
Benefits
Increased Capacity: More servers provide more processing power, memory, and storage, allowing you to handle higher loads efficiently.
Improved Fault Tolerance: If one server fails, the others can continue processing requests, enhancing system availability.
Flexibility and Cost-Effectiveness: You can add or remove servers as needed, offering better resource utilization and potentially lower costs compared to high-end hardware upgrades.
Challenges
Complexity: Managing multiple servers and ensuring workload distribution can become complex as the infrastructure scales.
Development Considerations: Your application needs to be designed to work efficiently in a distributed environment.
Vertical Scaling (Scale Up)
This approach focuses on upgrading the existing hardware resources of your server(s). You might add more CPU cores, RAM, or storage to enhance its processing power and capacity.
Analogy : Bring a bigger Boiler to make more momos.
Benefits:
Simpler Management: Less complex than managing multiple servers, especially for smaller applications.
Faster Implementation: Upgrading hardware can be quicker than setting up and configuring a distributed system.
Challenges:
Limited Scalability: There's eventually a physical limit to how much you can upgrade a single server.
Single Point of Failure: If the upgraded server fails, the entire system goes down.
Potentially Higher Cost: Upgrading high-end hardware can be more expensive than adding commodity servers in a horizontal scaling approach.
Choosing the Right Strategy:
The optimal scaling strategy depends on your specific needs and application characteristics. Horizontal scaling is generally preferred for large-scale, high-traffic web applications where flexibility and fault tolerance are crucial. Vertical scaling might be suitable for smaller applications with predictable workloads where simplicity and rapid deployment are priorities.
Momo Scaling
High Demand, Multiple Locations: If customer demand is high and you have the opportunity to expand geographically, horizontal scaling with new branches might be a good choice.
Limited Space, Growing Demand: If space limitations restrict new branches, consider vertical scaling by upgrading your kitchen equipment and processes to increase momo production capacity.
Greenfield vs. Brownfield Strategies: Building Software
Greenfield Strategy
This approach involves starting fresh with a new application from scratch. You have the freedom to choose the latest technologies, frameworks, and architecture without being constrained by existing legacy systems.
Benefits:
Modern and Efficient Technologies: You can leverage the latest advancements in software development to build a high-performance and scalable application.
Flexibility and Agility: The development process is less restricted by existing codebase limitations, allowing for faster iteration and adaptation.
Challenges:
Increased Development Time and Cost: Building a new application from scratch requires significant upfront investment in design, development, and testing.
Integration with Existing Systems: If you need to integrate with existing legacy systems, additional effort might be required.
Brownfield Strategy
This approach focuses on evolving and improving an existing application. You can gradually modernize, refactor, and enhance the existing codebase while keeping the core functionality intact.
Benefits:
Lower Development Cost: Leveraging existing code can save time and resources compared to a complete rewrite.
Reduced Risk: Modifying existing code is generally less risky than building a new system from scratch.
Challenges:
Technical Debt: Legacy code might have inefficiencies or outdated technologies, making modifications challenging.
Limited Flexibility: Existing code structure might limit the adoption of new features and functionalities.
Choosing the Right Strategy:
The choice between Greenfield and Brownfield strategies depends on various factors like the state of your existing application, budget, resource availability, and desired speed of change.
For completely new applications or outdated, inflexible legacy systems, a Greenfield approach might be more suitable. For gradually modernizing and enhancing existing functionality, a Brownfield strategy might be preferred. In some cases, a hybrid approach combining elements of both strategies can be effective.
Analogy :
Greenfield : You build a new house because you wanted to upgrade your lifestyle
Brownfield : You work on floor by floor remodeling, while relocating to different floors as the work progresses.