DEV Community

David
David

Posted on

Advice on Code Architecture for Beginners

Who this article is for:
You've already written your first 1000 lines of code and now want to make it more understandable, as making changes takes as much time as rewriting from scratch. However, the advice from OOP, SOLID, clean architecture, and others are unclear to you.

What this article is about:
This article is not an explanation of the principles of OOP and SOLID in my own words, but an attempt to create an intermediate level between no architecture and clean architecture. 100% of the advice will overlap and rephrase SOLID, but that's even better.

Who this article is from:
I am an ordinary developer. Of course, I am not a development guru, but who better than me to remember the problems I encountered when I first started my journey.

Disclaimer:
I am sure that each point of the article can be a subject of debate, but that's the nature of a free retelling. The entire article goes under the banner of "It's better to apply such architecture than not to apply any at all."

The format of the article - suggestive advice/questions.

Contents:

  • What the function is related to.
  • How you will modernize one function without affecting another.
  • How many logical parts can you break your function into?
  • Repeated words in function/variable names.
  • What are the central objects of your code.
  • Which similar functions can your function be replaced with?
  • What does the ideal pseudocode of your function look like?
  • Pay attention to the data format.
  • Prefer namespaces over branching.
  • Hide constant function arguments inside a separate function.

Tip number 1
When writing code and unsure how to organize it, ask yourself questions like:
"What does my function pertain to?" / "What does this functionality pertain to?" / "What is this functionality responsible for?"
Try to mentally assign hashtags to your function:
processing, validation, checking, database, display.
Certainly, a database query may be part of processing, but it can also be used for another function in the future, even if it is currently only written for this one.

Remark: In development, there is already an established set of such tags, some of them include: validate, check, get, set, show, load, send. This also includes CRUD and HTTP headers.

Tip number 2
Think about what might cause your function to be upgraded or what will force it to change.

Small changes should not significantly affect other functions.

For example, if you need to change the database query for a function that reads contacts, it should not be the same function as sending or displaying contacts.

Strive for a situation where adding new functionality comes down to creating a new method for a class, and possibly the appearance of a couple of new arguments for a function or chain of function calls.

There are tasks that require significant changes, but this happens infrequently.

Tip number 3
"What parts would I divide this functionality into?", "What other subfunctions can the code of this function be divided into?".

By recursively asking yourself this question, you will come to a point where the function becomes "atomic", and its functionality no longer makes logical sense to divide further (not to be confused with atomic operation).

def get_product_price():
… # Code here
Enter fullscreen mode Exit fullscreen mode

Even without knowing anything about this function and task requirements, I assume that the price calculation process can be broken down into N stages, for example:

  1. Apply the general percentage formula. - That very atomic operation. It is not possible to break down this action any further.

  2. Apply price restrictions. The product cannot cost less than a similar product from last year's collection, etc.

  3. Apply a discount. The discount cannot be negative, more than 100%, etc.

The two functions below can be common for the entire project and located in the "utils.py" module. Classes can use these functions with different arguments, creating a wrapper around them.

Note: Not all programmers approve of such a module in a project. I have come across an article criticizing this approach, but at this stage, it is quite justified.

def calculate_percentage_value(number: int, percentage: int) -> int:
    return number * (percentage / 100)


def limit_number(number: int, min_: int, max_: int, ) -> int:
    """Returns a number or a limit of a number."""
    return min(max(min_, number), max_)


def get_product_price(price: int, discount: int, ) -> int:
    min_discount = 10  # Better to palce inside class    
    max_discount = 20  # Better to palce inside class
    discount = calculate_percentage_number(number=price, percentage=discount, )
    discount = limit_number(
        number=discount,
        min_=min_discount,
        max_=max_discount,
    )
    discounted_price = price - discount
    if 0 < discounted_price < price:
        return discounted_price
    # Ignore discount in case of error. 
    logger.log(DiscountError)
    return price  # Or apply base discount
Enter fullscreen mode Exit fullscreen mode

Pay attention to how variable names change depending on the context: price -> number, discount -> percentage.

Hint: If a function can easily be written in a functional style (when our function calls another function as an argument), then the rule of division applies to it.

Of course, you don't need to immediately break your functionality into 1000 parts, as you won't need all of it (YAGNI principle), but you should be prepared for that possibility.
Hint: For percentages, you can create a separate type so as not to confuse them with regular numbers.

Advice number 4
Pay attention to the repeating "user" in function names.

def get_user_data():    
    ...


def notify_user_friends():    
    ...


def create_user_post():    
    ...
Enter fullscreen mode Exit fullscreen mode

Such repetitions are a clear sign that it's time to create a class for the repeating word:
In Python, a class is not only an object that should be created multiple times, but also a namespace (a convenient organization of functions).

Remark: Personally, I think the "class" instruction in Python is overloaded; it is a namespace, a data structure, and the classes themselves.

A better approach would be:

class User():
    def get_data():
        ...

    def notify_friends():
        ...
Enter fullscreen mode Exit fullscreen mode

Advice number 5
OOP revolves around objects/entities/models defined by the business/employer.

In a hypothetical messenger project, the "message" class would be large, while in a taxi project, the "message" class would be much smaller, but there would be a large "car" class.

Determine which classes in your project are central and fill them with methods.

Remark: Perhaps in the near future, some AI will create a universal structure for every object on Earth, and every project will have the same objects, but more likely, AI will simply learn to program better than us, without any code organization :)

In my practice, the beginning of any project is a small set of standard functions and classes, for example:
View, DB, User, Mail. They are used for general purposes.
Very quickly, in the taxi service, the Taxi class will outgrow the other classes and will have its own greeting method.

def some_func(user: User):
    ...
    View.say_hello(name=user.name, )  # Общее приветствие.
    taxi.say_hello(name=user.name, )  # Приветствие от конкретного такси.
    ...
Enter fullscreen mode Exit fullscreen mode

This may look suspicious, but the advantages of this approach outweigh the disadvantages.

The general say_hello method is placed in the common View class,
while taxi_say_hello is in the Taxi class.

This is not the most flexible solution, but in practice, this approach should be sufficient for a long time for small projects.

Remark: As far as I know, the MVC (Model-View-Controller) approach has both supporters and opponents.

Therefore, first and foremost, everything should depend on the project requirements.

Advice number 6
What can I REPLACE my function/class with?

Suppose you have a user class, and it has a method for sending data via email.
For this, you use some framework.

At some point, you decided to change this framework.

Old framework:

recipient = BarMailAgent.serialize_recipient(recipient=...) 
FooMailAgent.send(text=self.get_txt_data(), recipient=..., retry=3, delay=10)
Enter fullscreen mode Exit fullscreen mode

New framework:

# recipient serialization already inside the method
BarMailAgent.send(message=self.get_txt_data(), email=..., attempts=3, interval=10)
Enter fullscreen mode Exit fullscreen mode

FooMailAgent1 and BarMailAgent do roughly the same thing, but quickly replacing one with the other in the code won't work, as they have different arguments and actions.

It is better to create a universal class/method (or even better an inteface class) specifically for your code (considering the specifics) that will accept predefined arguments, and then pass them on to some method.

# On the way of understanding the interfaces :)
class User:
    def send_email(self, version: int = 1, arguments=...):
        if version == 1:
            recipient = BarMailAgent.serialize_recipient(recipient=...)
            FooMailAgent.send(text=self.get_txt_data(), recipient=..., retry=3, delay=10)
        else:
            # recipient serialization already inside the method
            BarMailAgent.send(message=self.get_txt_data(), email=..., attempts=3, interval=10)
Enter fullscreen mode Exit fullscreen mode

Advice number 7
First, write the ideal pseudocode, showing how your code should look in an ideal case, for example:

def register(user: User):
    user.validate()
    user.save()
    logger.log(event=events.registration, entity=user, )
    mail.send(event=events.registration, recipient=user.email, )
    notifier.notify(event=events.registration, recipients=user.possible_friends, )
    statistics.add_record(event=events.registration, recipient=user.email,)
Enter fullscreen mode Exit fullscreen mode

Remark: I use the rule: 1 line - 1 action.

This is done to make it easy to quickly scan through with your eyes.
When there is a lot of code, the main reason for errors is inattention, and it's good if tests will cover this case.

It's easy to forget to send an email, notify friends, etc., especially when the set of actions is constantly changing from management and the analytics team.

Somewhere outside the code may look like this:

def register_handler(update, context):
    try:
        events.register(user=context.user)
    except Exceptions.Registration.ValidationError:
        # Somewhere inside will be: "400. Incorrect field/fields (list of fields here) for user entity"
        events.fails.registration(user=user)
    except Exceptions.Registration.DbError:
        # Somewhere inside will be: "503. Internal error."
        events.fails.registration(user=user)
Enter fullscreen mode Exit fullscreen mode

I must admit that this code raises several non-critical doubts for me:

Should the try/except block be outside the "register" method?

Is it possible to pack "user" into "events.registration"?

Is it necessary to pass the whole user or only the required attributes?
On the one hand, this makes the code clearer; on the other hand, if the required set changes, more writing will be required.
I came to a compromise for myself:
If an attribute is an integral part of an object (email, phone, ID) - pass the whole object; otherwise - only the attribute.

In any case, this is a good option for architecture.

Advice number 8
Pay attention to the data format.

Some framework may pass an object called event/update to your handlers.

The validation functions only need the "user" attribute from this object,
and the database only needs the "ID" or "role" attribute from this object.

So, a conditional access check might look like this:
update / event - passed to the handler.
update.user - passed to the validation function.
user.id - passed to the database query.

You don't need to pass the entire update to the validation function, so your processing function can be used for multiple frameworks. This is what allows me to easily change my framework if I want.

My validation/check functions are not dependent on the data format provided by the framework.

Advice number 9
Give preference to namespaces over branching.
Each if/else branch complicates the code, creates potential for errors, and makes testing more difficult.

Remark: In architecture, there are code complexity metrics, and excessive branching worsens these indicators.

In theory, all APIs can be written using branches, but it is not necessary:

def global_handler(request):
    if request.url == 'settings':
        ...
    elif request.url == 'photos':
        ...
Enter fullscreen mode Exit fullscreen mode

Delegate branching to the language itself, as ultimately, a namespace can be represented as:

for key in namespace:
    if key == dot_value:
        return key

Enter fullscreen mode Exit fullscreen mode

Remark: Personally, apart from regular cases, I use a compromise approach, applying branching if the function's arguments depend on it, but not the function itself (and it won't depend in the future).
(The function can also be considered an argument, but this can often complicate the code, leave this for decorators).

Advice number 10
Hide constant function arguments inside a separate function.

Here, the argument 'hello' is always the same and doesn't carry any useful information when analyzing the code. In 9 out of 10 cases, when reading the code, we do NOT want to focus on the text being sent, but the code below forces us to do so.

The function is easy to read, but it could be even simpler.

# Use a constant variable instead of 'hello'
bot.send_message(text='hello', recipient=user.id)
Brevity is the soul of wit.
Enter fullscreen mode Exit fullscreen mode
View.say_hello(recipient=recipient) # bot.send_message is inside
Enter fullscreen mode Exit fullscreen mode

Thank you for your attention. I hope these insights prove valuable in your coding journey. By the way, I am available for hiring and eager to take on new challenges where I can apply my best experience. If you're interested in working together, please feel free to contact. Best of luck, and happy coding!

Top comments (0)