Implementing Clean Architecture in Python
Clean Architecture is a variant of Alistair Cockburn's hexagonal architecture. The main idea is to separate the business logic from the infrastructure. Proposed by Robert C. Martin in 2012, it combines the principles of hexagonal architecture, onion architecture and other variants, defining more precisely the responsibility of each layer and how they should communicate with each other.
A project is divided into different layers:
:quality(85)/https://andros.dev/media/blog/2024/05/clean-architecture.jpg)
- 🟡 Entities: Business variables/constants/classes/objects. The core of the application. For example, User, Product, Invoice, etc.
- 🔴 Use Cases: The implementation of the business logic. Mainly functions. For example, the logic to create a new user, calculate the total of an invoice, etc.
- 🟢 Gateways: The interfaces that the Use Cases need to interact with the outside world. For example, the database interface, the file system interface, etc.
- 🔵 External Interfaces: The applications that interact with the outside world.
- Database
- Frameworks
- ORM
- File system
- Devices
- External APIs
- UI
- HTTP (website)
- CLI (command line interface)
- API (REST API)
The main general rule is that inner layers cannot depend on outer ones: dependencies always point inwards. When an inner layer needs something from the outside world (a database, an external API), it does not import it directly: it receives an interface provided by the outer layer, and it only returns simple structures (in Python we will use dictionaries).
⬆️ Calls come in through interfaces | ⬇️ Results go out as simple structures
And we will never break these rules. Even when an exception occurs, we will handle it and return a structure.
Advantages
Let's go over some of the advantages why we should implement clean architecture in our projects.
- Maintenance and modularity: The separations make it easy to make changes without affecting other parts of the code.
- Easier testing: Since all components are independent, we can test them in isolation. We know what we receive and what we must return, regardless of the interface or technology behind it.
- Code reuse: We can reuse use cases in different interfaces, or entities in different use cases.
- Technology isolation: We can change elements such as frameworks, databases, UIs, etc. without affecting the business logic.
- Scalability: We can add new features without affecting the existing ones.
- Documentation: Having a well-defined structure, the documentation will be easier to write.
All of this results in more robust, cleaner and easier to maintain code.
Disadvantages
Not everything is rosy. Clean architecture also has its drawbacks:
- Increases initial development time: Having to define the structure and the interfaces, the initial development time can be longer. Thorough upfront planning is required.
- Learning curve: At first it can be difficult to understand how the different layers communicate. The team must be trained and know the architecture.
- Abstraction overhead: Creating many interfaces and layers can result in abstraction overhead. Be careful, it is not necessary to create an interface for every function, only for those that interact with the outside world.
- File fragmentation: Having different layers, each in its own folder, can result in a considerable file tree.
However, with practice and training, these disadvantages can be mitigated.
Simple implementation example in Python
Let's look at an example of a feature that calculates the installation price of a turbine. We will need, from the user, the number of turbines to install. The price for each turbine installation will be a constant.
The folder structure will be as follows.
- mi_proyecto: The main folder. The project name.
- core: Business logic.
- entities
- constants.py
- use_cases
- turbine
- calculate_turbine_installation.py
- turbine
- entities
- infra: Infrastructure or external interfaces.
- cli
- click
- src
- click
- api
- flask
- src
- fastapi
- src
- flask
- http
- django
- cli
- core: Business logic.
As we said before, the installation price will be a constant. We will create a constants.py file in the entities folder.
# mi_proyecto/core/entities/constants.py
INSTALLATION_PRICE = 1250
Business classes and objects also live in entities. In this article the constant is enough, but if the project grew, the turbine would have its own entity.
# mi_proyecto/core/entities/turbine.py
from dataclasses import dataclass
@dataclass
class Turbine:
model: str
power_kw: float
The use case will be in the use_cases folder.
# mi_proyecto/core/use_cases/turbine/calculate_turbine_installation.py
from mi_proyecto.core.entities.constants import INSTALLATION_PRICE
def calculate_turbine_cost_use_case(number_of_turbines: int) -> dict:
total = number_of_turbines * INSTALLATION_PRICE
return {"total": total}
The first external interface will be a REST API. In the example we will use Flask for simplicity.
We can have different implementations in different frameworks. Therefore, each repository (not to be confused with a versioning repository like Git) will have its own folder. In this situation, we will have a folder for Flask inside the api folder.
mi_proyecto/infra/api/flask/
We use the following code. We will get the input through a parameter present in the URL.
# mi_proyecto/infra/api/flask/src/app.py
from flask import Flask, jsonify
from mi_proyecto.core.use_cases.turbine.calculate_turbine_installation import (
calculate_turbine_cost_use_case,
)
app = Flask(__name__)
@app.route("/calculate-turbine-cost/<int:number_of_turbines>", methods=["GET"])
def calculate_turbine_cost(number_of_turbines):
result = calculate_turbine_cost_use_case(number_of_turbines)
return jsonify(result)
if __name__ == "__main__":
app.run()
Remember: the main idea is to keep the business logic separated from the external interfaces.
Now a new need appears: creating an API to interact with a mobile application. We are asked to use FastAPI for it.
Inside the api folder of infra we will create another one named fastapi.
mi_proyecto/infra/api/fastapi/
# mi_proyecto/infra/api/fastapi/src/main.py
from fastapi import FastAPI
from mi_proyecto.core.use_cases.turbine.calculate_turbine_installation import (
calculate_turbine_cost_use_case,
)
app = FastAPI()
@app.post("/calculate-turbine-cost")
def calculate_turbine_cost(number_of_turbines: int):
return calculate_turbine_cost_use_case(number_of_turbines)
Next, we are asked for a web page that shows the result in HTML to a human. We will use Django. Since it is a website, its folder will be in http.
mi_proyecto/infra/http/django/
# mi_proyecto/infra/http/django/views.py
from django.shortcuts import render
from mi_proyecto.core.use_cases.turbine.calculate_turbine_installation import (
calculate_turbine_cost_use_case,
)
def calculate_turbine_cost(request, number_of_turbines: int):
result = calculate_turbine_cost_use_case(number_of_turbines)
return render(request, "turbine.html", {"total": result["total"]})
<!-- mi_proyecto/infra/http/django/templates/turbine.html -->
<h1>Total: {{ total }} €</h1>
# mi_proyecto/infra/http/django/urls.py
from django.urls import path
from .views import calculate_turbine_cost
urlpatterns = [
path("calculate-turbine-cost/<int:number_of_turbines>/", calculate_turbine_cost),
]
Finally, why not a terminal client? We will use Click. Since it is a CLI, its folder will be in cli.
mi_proyecto/infra/cli/click/
# mi_proyecto/infra/cli/click/src/main.py
import click
from mi_proyecto.core.use_cases.turbine.calculate_turbine_installation import (
calculate_turbine_cost_use_case,
)
@click.command()
@click.option("--number_of_turbines", type=int, required=True)
def calculate_turbine_cost(number_of_turbines):
result = calculate_turbine_cost_use_case(number_of_turbines)
click.echo(result["total"])
if __name__ == "__main__":
calculate_turbine_cost()
Seen in a diagram, the four interfaces converge on the same point:
flowchart LR
subgraph infra["infra (external interfaces)"]
F["API with Flask"]
FA["API with FastAPI"]
D["Web with Django"]
C["CLI with Click"]
end
subgraph core["core (business logic)"]
U["calculate_turbine_cost_use_case"]
end
F --> U
FA --> U
D --> U
C --> U
Now we have 4 different interfaces for the business logic: an API with Flask, another API with FastAPI, an HTML website with Django and a CLI with Click. We can change the user interface without touching the heart of the application. Impressive, isn't it?
Another implementation example in Python with several external interfaces
Let's look at the previous example with a new need. The price will no longer come from a constant alone. Now the calculation needs 2 pieces of data that live in the outside world.
- tax. We will get it from a public API. We will use Spain as an example.
- Average salary of all our workers. We will get it from a database.
This creates a problem. The use case needs to talk to an API and to a database, but remember the rule: inner layers cannot depend on outer ones. How do we solve it? With dependency inversion. The use case will receive those pieces from the outside, as interfaces, without knowing what technology is behind them.
We will restructure the use case. Now we will use the number of turbines, the tax percentage and the average salary of the workers.
# mi_proyecto/core/use_cases/turbine/calculate_turbine_installation.py
from mi_proyecto.core.entities.constants import INSTALLATION_PRICE
def calculate_turbine_cost_use_case(
number_of_turbines: int, tax: float, average_salary: float
) -> dict:
total = number_of_turbines * INSTALLATION_PRICE
total += total * tax
total += average_salary
return {"total": total}
We will create a new folder inside the infra folder named gateways.
mi_proyecto/infra/gateways/
Let's create the interface for the tax API. We will use the requests library to make the request, calling a fictional API that returns a tax filtered by country.
# mi_proyecto/infra/gateways/tax.py
import requests
def get_tax() -> float:
response = requests.get("https://tax.com/", params={"country": "Spain"}, timeout=10)
return response.json()["tax"]
The next step is to create the interface for the database. We will use SQLite for the example.
# mi_proyecto/infra/database/sqlite_repo.py
import sqlite3
from typing import Any
class SQLiteRepo:
def __init__(self, connection_string: str):
self.connection = sqlite3.connect(connection_string)
# Rows will behave like dictionaries
self.connection.row_factory = sqlite3.Row
def fetch_one(self, table: str, key: str, columns: list[str] | None = None) -> Any:
"""
Fetch one row from a table
:param table: The table name
:param key: The key to fetch
:param columns: The columns to fetch. If None, fetch all columns
:return: A row
"""
columns_str = "*" if columns is None else ",".join(columns)
return self.connection.execute(
f"SELECT {columns_str} FROM {table} WHERE key = ?", (key,)
).fetchone()
def fetch_all(self, table: str, columns: list[str] | None = None) -> list[Any]:
"""
Fetch all rows from a table
:param table: The table name
:param columns: The columns to fetch. If None, fetch all columns
:return: A list of rows
"""
columns_str = "*" if columns is None else ",".join(columns)
return self.connection.execute(f"SELECT {columns_str} FROM {table}").fetchall()
Two details about the repository: we enable sqlite3.Row so that each row behaves like a dictionary, and the value of key is passed as a query parameter, never interpolated into the SQL, to avoid injections.
Now we will modify the use case to use the interfaces.
# mi_proyecto/core/use_cases/turbine/calculate_turbine_installation.py
from mi_proyecto.core.entities.constants import INSTALLATION_PRICE
def calculate_turbine_cost_use_case(repo, get_tax, number_of_turbines: int) -> dict:
tax = get_tax()
salaries = repo.fetch_all("workers", ["salary"])
average_salary = sum(row["salary"] for row in salaries) / len(salaries)
total = number_of_turbines * INSTALLATION_PRICE
total += total * tax
total += average_salary
return {"total": total}
We have added two new parameters to the use case: repo, the database interface, and get_tax, the tax API interface. Notice that the use case no longer imports anything from infra: it receives its dependencies from the outside. This is known as dependency inversion, and it is what will allow us to change the database or the tax provider without affecting the business logic.
You may wonder where the interface is defined. In Python there is no need to declare it formally: any object with a fetch_all method works as a repository (duck typing). If you prefer an explicit contract, you can define a Protocol inside core and use it as the type of the repo parameter. The infra implementations will fulfill it without inheriting from anything.
# mi_proyecto/core/gateways/repository.py
from typing import Any, Protocol
class Repository(Protocol):
def fetch_one(
self, table: str, key: str, columns: list[str] | None = None
) -> Any: ...
def fetch_all(self, table: str, columns: list[str] | None = None) -> list[Any]: ...
When we call the use case from an interface, we will pass it the database and tax API interfaces.
# mi_proyecto/infra/api/flask/src/app.py
import os
from flask import Flask, jsonify
from mi_proyecto.core.use_cases.turbine.calculate_turbine_installation import (
calculate_turbine_cost_use_case,
)
from mi_proyecto.infra.database.sqlite_repo import SQLiteRepo
from mi_proyecto.infra.gateways.tax import get_tax
app = Flask(__name__)
@app.route("/calculate-turbine-cost/<int:number_of_turbines>", methods=["GET"])
def calculate_turbine_cost(number_of_turbines):
repo = SQLiteRepo(os.environ.get("CONNECTION_STRING"))
result = calculate_turbine_cost_use_case(repo, get_tax, number_of_turbines)
return jsonify(result)
And this is how the terminal client with Click from the first example evolves: only what we inject changes.
# mi_proyecto/infra/cli/click/src/main.py
import os
import click
from mi_proyecto.core.use_cases.turbine.calculate_turbine_installation import (
calculate_turbine_cost_use_case,
)
from mi_proyecto.infra.database.sqlite_repo import SQLiteRepo
from mi_proyecto.infra.gateways.tax import get_tax
@click.command()
@click.option("--number_of_turbines", type=int)
def calculate_turbine_cost(number_of_turbines):
repo = SQLiteRepo(os.environ.get("CONNECTION_STRING"))
result = calculate_turbine_cost_use_case(repo, get_tax, number_of_turbines)
click.echo(result)
if __name__ == "__main__":
calculate_turbine_cost()
We do not break the rule: the inner layers still know nothing about the outer ones. The use case receives its dependencies as interfaces and returns simple structures.
This is the complete journey of a request, from the outside in and back:
sequenceDiagram
autonumber
participant U as User
participant F as Flask (external interface)
participant C as Use case (core)
participant T as Tax gateway (infra)
participant R as SQLite repository (infra)
U->>F: GET /calculate-turbine-cost/10
F->>C: calculate_turbine_cost_use_case(repo, get_tax, 10)
C->>T: get_tax()
T-->>C: 0.21
C->>R: fetch_all("workers", ["salary"])
R-->>C: rows with the salaries
C-->>F: {"total": 16625.0}
F-->>U: JSON with the total
Notice that the call arrows always point inwards and that the use case only talks to repo and get_tax: it does not know that behind them there is SQLite and a public API, nor does it care. And what comes back out is always a simple structure, a dictionary.
Now it turns out that we must replace the repository with a JSON file on disk. We will only have to change the implementation of the interface.
# mi_proyecto/infra/database/json_repo.py
import json
from typing import Any
class JSONRepo:
def __init__(self, file_path: str):
self.file_path = file_path
def fetch_one(self, table: str, key: str, columns: list[str] | None = None) -> Any:
"""
Fetch one row from a table
:param table: The table name
:param key: The key to fetch
:param columns: The columns to fetch. If None, fetch all columns
:return: A row
"""
with open(self.file_path, "r") as file:
data = json.load(file)
return data[table][key]
def fetch_all(self, table: str, columns: list[str] | None = None) -> list[Any]:
"""
Fetch all rows from a table
:param table: The table name
:param columns: The columns to fetch. If None, fetch all columns
:return: A list of rows
"""
with open(self.file_path, "r") as file:
data = json.load(file)
return list(data[table].values())
The use case does not change at all. We only change the implementation that we inject from the external interface.
# mi_proyecto/infra/api/flask/src/app.py
import os
from flask import Flask, jsonify
from mi_proyecto.core.use_cases.turbine.calculate_turbine_installation import (
calculate_turbine_cost_use_case,
)
from mi_proyecto.infra.database.json_repo import JSONRepo # New
from mi_proyecto.infra.gateways.tax import get_tax
app = Flask(__name__)
@app.route("/calculate-turbine-cost/<int:number_of_turbines>", methods=["GET"])
def calculate_turbine_cost(number_of_turbines):
repo = JSONRepo(os.environ.get("JSON_DB_PATH")) # Change
result = calculate_turbine_cost_use_case(repo, get_tax, number_of_turbines)
return jsonify(result)
We have only modified the database implementation, without touching a single line of the business logic.
Error handling
We have been working with controlled cases, where we receive perfectly structured data. Reality is messier.
In clean architecture we cannot return Python exceptions, besides the fact that we would break the rule of only returning dictionaries. For this we will slightly modify the return structure.
If everything went well, we will return a dictionary with the type key set to Success. If there was an error, we will return a dictionary with the type key set to Error and a dictionary with the errors.
For example:
calculate_turbine_cost_use_case(repo, get_tax, 10)
"""
{
"type": "Success",
"errors": [],
"data": {
"total": 16625.0
}
}
"""
In the error case.
calculate_turbine_cost_use_case(repo, get_tax, "foo")
"""
{
"type": "ParametersError",
"errors": [
{
"field": "number_of_turbines",
"message": "Number of turbines is required"
}
],
"data": {}
}
"""
To restrict the error types, we will create a class with the error types.
# mi_proyecto/core/use_cases/turbine/calculate_turbine_installation.py
from mi_proyecto.core.entities.constants import INSTALLATION_PRICE
class ResponseTypes:
SUCCESS = "Success" # The process ended correctly
PARAMETERS_ERROR = "ParametersError" # Missing or invalid parameters
RESOURCE_ERROR = "ResourceError" # The process ended correctly but the resource is not available (DB, file, etc)
SYSTEM_ERROR = "SystemError" # The process ended with an error. Python error
def calculate_turbine_cost_use_case(repo, get_tax, number_of_turbines: int) -> dict:
# Check if the number of turbines is present and valid
if not isinstance(number_of_turbines, int) or number_of_turbines <= 0:
return {
"type": ResponseTypes.PARAMETERS_ERROR,
"errors": [
{
"field": "number_of_turbines",
"message": "Number of turbines is required",
}
],
"data": {},
}
# Logic
try:
tax = get_tax()
salaries = repo.fetch_all("workers", ["salary"])
except Exception as error:
return {
"type": ResponseTypes.SYSTEM_ERROR,
"errors": [{"field": "system", "message": str(error)}],
"data": {},
}
if not salaries:
return {
"type": ResponseTypes.RESOURCE_ERROR,
"errors": [{"field": "salaries", "message": "Salaries not found"}],
"data": {},
}
average_salary = sum(row["salary"] for row in salaries) / len(salaries)
total = number_of_turbines * INSTALLATION_PRICE
total += total * tax
total += average_salary
return {"type": ResponseTypes.SUCCESS, "errors": [], "data": {"total": total}}
If the input is wrong, or we cannot get the data we need from the database, we will return a dictionary with the error type and the errors. And if any Python exception occurs (the tax API does not respond, the database is corrupted), we catch it and turn it into a SystemError structure: the exception never leaves the use case. Otherwise we will return a dictionary with the Success type and the data.
If we implement it in an external interface such as an API, we will return a status code depending on each error type.
200forSuccess.400forParametersError.500forSystemError.503forResourceError.
We could create a decorator for that purpose. Before returning the result, it will check the type returned by the use case and modify the status code before responding.
If we were working with FastAPI, a simple implementation would be the following.
from functools import wraps
from fastapi import FastAPI, Response, status
app = FastAPI()
class ResponseTypes:
# The process ended correctly. HTTP 200
SUCCESS = "Success"
# Missing or invalid parameters. HTTP 400
PARAMETERS_ERROR = "ParametersError"
# The process ended with an error. Python error. HTTP 500
SYSTEM_ERROR = "SystemError"
# The process ended correctly but the resource is not available (DB, file, etc). HTTP 503
RESOURCE_ERROR = "ResourceError"
def correct_http_code(func):
"""
Adjust the HTTP code based on the response type
"""
@wraps(func)
async def wrapper(*args, **kwargs):
response = kwargs.get("response")
output = await func(*args, **kwargs)
status_type = output.get("type")
response.status_code = status.HTTP_200_OK
if status_type == ResponseTypes.PARAMETERS_ERROR:
response.status_code = status.HTTP_400_BAD_REQUEST
elif status_type == ResponseTypes.RESOURCE_ERROR:
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
elif status_type == ResponseTypes.SYSTEM_ERROR:
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
return output
return wrapper
def calculate_turbine_cost_use_case(number_of_turbines: int) -> dict:
pass
@app.post("/api/calculate-turbine-cost")
@correct_http_code
async def calculate_turbine_cost(response: Response, payload: dict | None = None):
return calculate_turbine_cost_use_case((payload or {}).get("number_of_turbines"))
There is a problem when sending or receiving data. Since we use JSON to communicate, we will receive and send Camel Case, but in Python we use Snake Case. We can configure FastAPI to do the conversion automatically.
Type and structure validation
We continue digging into the possible errors. The data we may receive in the use cases can be wild, with inappropriate structures and types. To reduce the complexity we can use a widely known library in the Python ecosystem called pydantic. It is a library for validating types. It would be a good idea to automate the boring task with a decorator in charge of returning the errors in the format we are using in the architecture.
from functools import wraps
from pydantic import ValidationError
class ResponseTypes:
SUCCESS = "Success" # The process ended correctly
PARAMETERS_ERROR = "ParametersError" # Missing or invalid parameters
RESOURCE_ERROR = "ResourceError" # The process ended correctly but the resource is not available (DB, file, etc)
SYSTEM_ERROR = "SystemError" # The process ended with an error. Python error
def check_params(Model):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
params = kwargs.get("params", None)
if params is not None:
try:
Model.model_validate(params, strict=True)
except ValidationError as e:
errors = []
for error in e.errors():
errors.append(
{
"field": error["loc"][0],
"message": error["msg"],
}
)
return {
"type": ResponseTypes.PARAMETERS_ERROR,
"errors": errors,
"data": {},
}
return func(*args, **kwargs)
return wrapper
return decorator
We define the Pydantic model, the structure we are looking to receive. As an example, we will receive a user's information.
from pydantic import BaseModel
class UserInfoModel(BaseModel):
id: int
name: str
is_active: bool
weight: float
favorites: list[int]
To use it, we just add the decorator to the use case along with the previous model.
@check_params(UserInfoModel)
def set_user_info(params):
return {
"type": ResponseTypes.SUCCESS,
"errors": [],
"data": {},
}
We can now receive data.
inputExample = {
"id": 1,
"name": "John Doe",
"is_active": True,
"weight": 75.5,
"favorites": [1, 2, 3],
}
set_user_info(params=inputExample)
# {'type': 'Success', 'errors': [], 'data': {}}
If the data is not correct, we will return a dictionary with the error type and the errors.
inputExample = {
"id": False,
"name": 23,
"is_active": True,
"weight": 75.5,
"favorites": [1, 2, 3],
}
set_user_info(params=inputExample)
# {'type': 'ParametersError', 'errors': [{'field': 'id', 'message': 'Input should be a valid integer'}, {'field': 'name', 'message': 'Input should be a valid string'}], 'data': {}}
It is important that when using set_user_info, we pass the params parameter so that the decorator can validate the data.
Now our use cases are protected from incorrect data and the validation process is automated.
Testing
What should we test? We actually answered this question at the beginning, and I am sure you see it more clearly after reading the previous example. We must test the use cases, not the rest of the layers (the API in this case). FastAPI is just an interface that receives data and sends it to the use cases; it does not care about the structure of the received dictionary (JSON), whether it is correct or not. Validating is the responsibility of the use cases, in addition to giving us the list of errors if there are any. The only thing we could check is whether the API returns the correct status code.
Let's see how this advantage materializes with pytest. Thanks to dependency inversion we need neither a real database nor a real tax API: we create fake implementations in a couple of lines and inject them.
# tests/use_cases/test_calculate_turbine_installation.py
from mi_proyecto.core.use_cases.turbine.calculate_turbine_installation import (
ResponseTypes,
calculate_turbine_cost_use_case,
)
class FakeRepo:
def fetch_all(self, table, columns=None):
return [{"salary": 1000}, {"salary": 2000}]
def fake_get_tax() -> float:
return 0.21
def test_calculate_turbine_cost_returns_the_total():
# Given
repo = FakeRepo()
number_of_turbines = 10
# When
result = calculate_turbine_cost_use_case(repo, fake_get_tax, number_of_turbines)
# Then
assert result["type"] == ResponseTypes.SUCCESS
# 10 * 1250 = 12500, plus 21% tax = 15125, plus 1500 of average salary
assert result["data"]["total"] == 16625.0
def test_calculate_turbine_cost_requires_a_valid_number():
# Given
repo = FakeRepo()
number_of_turbines = "foo"
# When
result = calculate_turbine_cost_use_case(repo, fake_get_tax, number_of_turbines)
# Then
assert result["type"] == ResponseTypes.PARAMETERS_ERROR
assert result["errors"][0]["field"] == "number_of_turbines"
Notice that the test knows nothing about Flask, FastAPI, SQLite or requests. It only knows the use case, its injected dependencies and the dictionary it returns.
Final notes
Only the business logic has been modified, not the external interfaces. Therefore the APIs and the CLI remain the same, without changes. That is the magic of clean architecture.
I hope this article has helped you understand how to implement clean architecture and where to start implementing it in Python. Even though the examples are simple, you will be able to apply it to bigger projects. My advice is not to stop here. Keep learning by reading books specialized in design patterns and software architecture, and practice a lot to apply the concepts.
- Advantages
- Disadvantages
- Simple implementation example in Python
- Another implementation example in Python with several external interfaces
- Error handling
- Type and structure validation
- Testing
- Final notes
This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.
Will you buy me a coffee?
This is how I keep writing without ads or paywalls.
Sure, it's on me!
Comments
There are no comments yet.