DEV Community

Cover image for CRUD - An honest copy-paste recipe to use guilt-free
Marcos Filipe Capella
Marcos Filipe Capella

Posted on

CRUD - An honest copy-paste recipe to use guilt-free

My days as a Programming TA at the Catholic University of Pernambuco are over, but I still have that drive to help out peer-to-peer. Lately, I’ve seen a lot of talk out there claiming that junior developers can’t write a CRUD without generative AI. I find that a bit sensationalist because the "recipe" is simple and applies to almost every language. #NoGateKeeping

This recipe is something I keep in my notebooks and update as I learn. The first version is from 2023, back when I was taking Object-Oriented Programming and studying SOLID. I’ve adapted it as new projects came along, and this is the result.

Ready to Ctrl+

Repository

The repository is the layer in contact with the Database. Depending on the framework or language, this will change quite a bit, but in general, we should have these methods:

# REPOSITORY LAYER (Abstract)

save(entity) -> entity_with_id
find_by_id(id) -> entity | None
update(entity) -> entity
delete(id) -> bool
exists(id) -> bool
find_all(filters, pagination) -> List[entity]

Enter fullscreen mode Exit fullscreen mode

Service

The Service layer is where the CRUD itself lives. Kent Beck suggests that as a best practice, developers should write code that meets reading expectations and follows conventions. Not straying from the expected is essential. I always make sure to write in the C-R-U-D order.

# SERVICE LAYER

create(attr1, attr2, attr3):
    # 1) Guard clauses - business logic validations
    # 2) [Optional] Check for duplicates/business rules via repository
    # 3) Create entity/domain object
    # 4) Call repository.save(entity)
    # 5) Return DTO or created ID

read(id):
    # 1) Guard clause - validate ID
    # 2) Call repository.find_by_id(id)
    # 3) Guard clause - check if found (raise NotFound if not)
    # 4) Return entity or DTO

update(id, update_attributes):
    # 1) Guard clauses - validate ID and attributes
    # 2) Call repository.find_by_id(id)
    # 3) Guard clause - check if found
    # 4) Apply changes to the entity (using entity methods, not direct setters)
    # 5) [Optional] Post-update business validations
    # 6) Call repository.update(entity) or save(entity) - depends on the framework
    # 7) Return updated entity or success response

delete(id):
    # 1) Guard clause - validate ID
    # 2) [Optional] Check existence via repository.exists(id)
    # 3) [Optional] Business validations (Can it be deleted? Does it have dependencies?)
    # 4) Call repository.delete(id)
    # 5) Return success response

Enter fullscreen mode Exit fullscreen mode

Note: In the create and update methods, I like returning the entity because it saves the front-end from having to rebuild the object with a new call, but there are valid criticisms of this—which is why I noted "OR". A service can also have other CRUD methods, like a "read" using an attribute other than the ID (e.g., searching for a user by email).

Controller

These are our endpoints! Did you get the flow so far? The controller builds the endpoint methods; it's the external layer. It calls the service, which performs the CRUD operations by calling methods from the repository (the innermost layer closest to the Database).

The controller handles HTTP requests and exceptions. Personally, I check if I’m following the Single Responsibility Principle (SRP) just by looking at the imports. If I see an HTTP library being called in the service, I know I’ve messed up.

Below, I’m using FastAPI as the standard, which is what I work with today in both my job and research, but this recipe was originally created when I was primarily coding in Java Spring Boot.

# CONTROLLER LAYER (FastAPI)

@router.post("/resource", status_code=201)
async def create_resource(request: CreateResourceDTO):
    # 1) [Optional] Extra input validations (Pydantic already does a lot)
    # 2) Call service.create(request.attr1, request.attr2, ...)
    # 3) [Exception Handling] Catch business errors and convert to HTTP
    # 4) Return formatted response (201 Created + Location header if pure REST)

@router.get("/resource/{id}")
async def get_resource(id: int):
    # 1) Call service.read(id)
    # 2) [Exception Handling] NotFound -> 404, etc.
    # 3) Return formatted response (200 OK + entity)

@router.put("/resource/{id}")  # or PATCH for partial replacement
async def update_resource(id: int, request: UpdateResourceDTO):
    # 1) Call service.update(id, request)
    # 2) [Exception Handling]
    # 3) Return formatted response (200 OK + updated entity)

@router.delete("/resource/{id}", status_code=204)
async def delete_resource(id: int):
    # 1) Call service.delete(id)
    # 2) [Exception Handling]
    # 3) Return 204 No Content (no body)

Enter fullscreen mode Exit fullscreen mode

So, here is my honest, "straight-from-the-heart" recipe to use for life. Ready for the Ctrl+V?


This article was originally posted by me in Portuguese on my LinkedIn account.

Top comments (0)