Tinkerings

Building a Portfolio Data Service: FastAPI, MongoDB, and AWS Lambda

Date Published

A note on what this one actually is: it's the backend that once fed my Angular portfolio website. The frontend needs to render blog posts, menu items, projects, and skills — all stored in MongoDB — and this service is the read API for that content. It runs locally under Uvicorn, on AWS Lambda behind API Gateway, and as a container. Same code, three deployment targets.


The STAR below is about the decisions that made that possible.


## Situation


An Angular portfolio site needs dynamic content: blog posts, project cards, skill categories, menu items. That content lives in MongoDB. I needed a service that could serve that content to the frontend over HTTP, work equally well locally and in production, and not compromise on secrets management — since baking credentials into a Lambda deployment package is a well-known way to leak them.


## Task


Build a Python 3.12 microservice that:


- Serves read endpoints for blog, menu, projects, and skills

- Talks to MongoDB asynchronously

- Abstracts the database behind an interface so it can be swapped

- Uses dependency injection rather than hardcoded clients

- Deploys to AWS Lambda via SAM, with Mangum as the ASGI adapter

- Handles secrets properly in production


## Action


**1. Defined an abstract `DatabaseManagerInterface` with the operations the service actually needs.**

`connect_to_database`, `close_database_connection`, `all`, `one_item`, and `create_item` are declared as abstract methods. The routers depend on this interface, not on MongoDB directly. The database is a detail; the contract is the abstraction.


**2. Implemented the interface concretely as `MongoManager`.**

`MongoManager` uses `AsyncIOMotorClient` — Motor, the async MongoDB driver — so every database call is genuinely non-blocking. The class holds `client` and `db` as typed attributes and implements the full interface.


**3. Injected the database service into every route via FastAPI's `Depends`.**

```python

async def all_posts(

database_manager_service: DatabaseManagerInterface = Depends(get_database)

):

```

No route instantiates its own client. FastAPI supplies the service. Swapping implementations is one line in `get_database`, not a rewrite of every endpoint.


**4. Configured connection pooling on the Mongo client.**

```python

self.client = AsyncIOMotorClient(path, maxPoolSize=10, minPoolSize=10)

```

The pool is bounded on both ends. Maximum prevents unbounded socket growth under load; minimum avoids reconnect churn at low traffic. Neither number is dramatic, but the choice is deliberate.


**5. Ping the database on connect to verify the credentials work.**

```python

await self.client.admin.command('ping')

```

Rather than discovering a bad connection on the first query, the startup hook fails fast if the credentials or the endpoint are wrong. The error is logged at the point of connection, not deferred.


**6. Used FastAPI's startup and shutdown hooks for the connection lifecycle.**

```python

@app.on_event("startup")

async def startup():

await database_manager_service.connect_to_database(...)


@app.on_event("shutdown")

async def shutdown():

await database_manager_service.close_database_connection()

```

The connection is opened once when the service starts and closed cleanly when it stops. Not opened and closed per request. Not left dangling. The lifecycle is explicit.


**7. Loaded configuration through Pydantic `BaseSettings` with `@lru_cache`.**

```python

@lru_cache()

def load_config():

class Config(BaseSettings):

APP_NAME: str = "Portfolio Data Read Service - Local"

PORTFOLIO_DATABASE_PATH: str = getEnvVar("PORTFOLIO_DATABASE_PATH")

PORTFOLIO_DATABASE_NAME: str = getEnvVar("PORTFOLIO_DATABASE_NAME")

return Config()

```

Typed configuration, read from the environment, validated by Pydantic. The `@lru_cache` decorator means the config is loaded once — reading from disk and parsing environment variables is not repeated on every request.


**8. Abstracted environment variables through helper functions.**

`getEnvVar`, `isEnvVar`, and `isLocal` provide a single place where environment lookups happen. `getEnvVar` returns a sentinel string rather than raising or returning `None`, which means configuration can be inspected and logged safely. `isLocal` checks `AWS_SAM_LOCAL` — the flag SAM sets when running locally — so code can branch on deployment context without environment-specific builds.


**9. Wrapped the application in a Mangum handler.**

```python

handler = Mangum(app, lifespan="off")

```

Mangum translates AWS Lambda's event-and-context signature into the ASGI interface FastAPI expects. The same `app` object runs under Uvicorn locally and behind API Gateway in production. No separate Lambda wrapper, no divergent code paths.


**10. Organised routers by resource, not by HTTP verb.**

`blog_router`, `menu_router`, `projects_router`, `skills_router` are each in their own file and mounted under a prefix:

```python

router.include_router(blog_router, prefix='/blog', tags=["Blog"])

router.include_router(menu_router, prefix='/menu', tags=["Menu"])

```

The URL structure mirrors the content model. Adding a new resource means adding a new router file and one line to the top-level router — no changes to the others.


**11. Custom error handlers for HTTP and validation errors.**

Two handlers are registered — `http_error_handler` and `http_422_error_handler` — which normalise FastAPI's default error responses into a consistent shape. The 422 handler converts Pydantic's validation error structure into a "body: [{field: message}]" form. Consumers get a predictable error contract rather than one that varies by endpoint.


**12. Stripped MongoDB's underscore prefix at the API boundary.**

```python

def removeFirstHyphen(s: str):

if (s[0] == "_"):

return s[1:]

else:

return s

```

MongoDB's convention is `_id`; JSON consumers expect `id`. The helper is applied during the serialisation in `MongoManager`, so the database's internal convention never leaks into the API response. The transformation happens at one boundary, not in every consumer.


**13. Validated ObjectIds at the boundary with a proper HTTP error.**

```python

def validate_object_id(object_id) -> any:

if object_id is None:

raise HTTPException(status_code=404, detail="ID is invalid")

return object_id

```

An invalid identifier produces a 404 with a clear message, not a 500 stack trace. The check is centralised so it applies wherever it is used.


**14. Modelled request and response data with Pydantic.**

`Blog_Post_Data`, `Blog_Post_Category_Data`, `Blog_Request_One`, and the corresponding models for skills and projects live in their own files. The shapes are declared, not inferred. Renaming a field in one place propagates as a type error wherever it is used.


**15. Documented the reasoning for rejecting `.env` and SSM for production secrets.**

The README records the decision explicitly: a `.env` file baked into a Lambda deployment package is visible in the AWS console, is at risk of accidental commit, duplicates across repositories, and has a chicken-and-egg problem about where the decryption key itself is stored. A SSM-encrypted environment variable via `serverless.yml` is not ideal either. The conclusion — moving to KMS — is not just a tool choice; it's a decision that was reasoned about and recorded.


## Result


A FastAPI microservice that serves portfolio content from MongoDB, with a swappable database layer behind an abstract interface, dependency injection at every route, an asynchronous client, a clean connection lifecycle tied to the application's startup and shutdown, typed configuration loaded once and cached, and a Mangum adapter that lets the same code run under Uvicorn locally and on Lambda in production.


The service is small — a read API for a portfolio site does not need to be more than that. But it uses the tools in front of it correctly: FastAPI's DI system, Pydantic's settings for config, Motor for async Mongo, Mangum for Lambda, and SAM templates for deployment. The result is a service with clear seams — every layer can be swapped, every dependency is injected, every external call has a defined failure mode.


The most interesting thing in the code is not any particular file. It's the accumulated reasoning: the interface over the implementation, the DI over the singleton, the startup hook over the per-request connect, the abstract environment helper over scattered `os.environ` access, and the written-down reasoning about why `.env` files in Lambda are a bad idea. Each of those is a small choice. Together they produce something that behaves predictably in production and remains readable a year later — which is what backend code is actually for.