ad
What 10,000 Hours of Coding Taught Me: Don't Ship Fast

What 10,000 Hours of Coding Taught Me: Don't Ship Fast

I’ve been an engineer for over 7 years now. I have worked on countless projects in backend, frontend, and DevOps. I don’t consider myself a great engineer; there are people out there who are not only smarter but also more experienced. Over the years, I have learned some tricks to help me climb the programming ladder, allowing me to build software that is reliable and easy to work with.

Being slow has made me code faster, ship more, and be more productive in general. This didn’t come only from years of coding but also from life lessons and my religion. As an Orthodox Christian, you have to always be slow and not rush your moves.

The #1 Problem with Software

Most people, when they start to code, think that great engineers are magicians who build applications in a unique way that no one can understand. That is very far from the truth. If you actually take a look at great engineers' code, you will find it very simple and easy to navigate and understand.

Your application doesn’t need to be fast, fancy, or use cutting-edge technology for you to be considered a good programmer. Managers also make that mistake. They hire people based on things that are far from the truth.

Giving coding tests where you have to build an application from scratch is a terrible way to judge someone's skills. Whiteboard interviews are actually better than coding challenges that give you 3 days to complete because at least they take a look at your IQ and your way of thinking.

Now, let's talk about the #1 problem that everyone should focus on but often overlooks:

Developer Experience

This is the most important thing in the entire project 99% of the time. Because everybody wants to ship fast to make money, but they end-up building 90% of the application in 1 month and the last 10% takes them 3 months to finish.

Developers are also hyped up for new projects so they try to take the quick dopamine to just have something to showcase as fast as possible and make there managers happy as well.

They end-up making there managers happy, yet in the long-run everybody is panicking and they are considering refactoring or even building the application from scratch after 4-5 years.

That’s why actually creating features becomes really hard and even harder to push them to production. This creates a snowball effect.

Let’s take a look at 2 code examples. We have 2 controllers that get the trending users from our database and attach emojis to them. This is from one of my open-source projects reporanger.xyz.

This is the controller that is being called from the routes. In there we have all the functionality as well as a try-catch block to check for any errors as well.

// users.controller.ts
const getTrendingUsers = async (_req: Request, res: Response, _next: NextFunction) => {
  try {
    const events = await GithubEvent.find({
      where: { event_date: MoreThan(new Date(Date.now() - 24 * 60 * 60 * 1000)) },
      order: { event_size: 'DESC' },
      take: 3,
    });
    const users = await Username.find({ where: { id: In(events.map((event) => event.username_id)) } });
    const topUsers = await getTopUsers(3);
    const trendingUsers = await Promise.all(
      users.map(async (user) => ({
        ...user,
        emoji: await emojiService.getEmoji(user.score, topUsers),
      })),
    );
    res.status(200).json({
      status: 200,
      message: 'Trending users fetched successfully',
      data: trendingUsers,
      error: '',
      success: true,
    });
  } catch (error) {
    res.status(500).json({
      status: 500,
      message: 'Error fetching trending users',
      data: null,
      error: error.message,
      success: false,
    });
  }
};

Here is the same controller but we’ve used one simple principle: Unique Responsibility

We spitted our code in 4 smaller re-usable files:
user.controller.ts
user.service.ts
async.util.ts
response.util.ts

This help’s us in many more ways than we can understand.

  1. We can re-use the user.service.ts everywhere in our application we want. For example on a cron-job we can do usernameService.getTrendingUsers();

  2. We removed every try-catch block to make the code cleaner. We also log every error (logger('error', error);). This way, we can easily create an error service later that stores all errors in the database for future use-cases.

  3. We have unified responses for all of our controller with the resFn , this is really important because we make sure that all of the request will return that same response by just using a simple generic as you can see below.

  4. Developing a new controller is now 10X easier, because our coding architecture is seamless across our entire application. Even if another developer wrote code it wouldn’t make a difference on the coding style.

// user.controller.ts
const getTrendingUsers = asyncFn(async (_req: Request, res: Response, _next: NextFunction) => {
  const trendingUsers = await usernameService.getTrendingUsers();
  resFn(res, {
    status: 200,
    message: 'Trending users fetched successfully',
    data: trendingUsers,
    error: '',
    success: true,
  });
});

// user.service.ts
const getTrendingUsers = async () => {
  const events = await GithubEvent.find({
    where: { event_date: MoreThan(new Date(Date.now() - 24 * 60 * 60 * 1000)) },
    order: { event_size: 'DESC' },
    take: 3,
  });
  const users = await Username.find({ where: { id: In(events.map((event) => event.username_id)) } });
  const topUsers = await getTopUsers(3);
  const usersWithEmoji = await Promise.all(
    users.map(async (user) => ({
      ...user,
      emoji: await emojiService.getEmoji(user.score, topUsers),
    })),
  );
  return usersWithEmoji;
};

// async.util.ts
export const asyncFn = (fn: asyncPropsFunction) => async (req: Request, res: Response, next: NextFunction) => {
  try {
    await fn(req, res, next);
  } catch (error) {
    logger('error', error);
    next(error);
  }
};

// response.util.ts
export const resFn = (res: Response, { status, error, data, message, success }: IResponse<any>) => {
  const suc = success !== undefined ? success : true;

  res.status(status).json({
    error,
    data,
    message,
    success: suc,
    status,
  });
};

// response.interface.ts
export interface IResponse<T> {
  status: number;
  message: string;
  data: T | any;
  error: string;
  success: boolean;
}

Now imagine if we didn’t implement a good architecture from the beginning and wanted to change something small across the application in the future. Even if we just wanted to log our errors, we would have to go to all of our controllers and add logger('error', error);. Or if we wanted to add a sixth field to our response, for example, metadata. It would be a nightmare.

Do the Refactoring First

Refactoring should be done before you write your code. What I mean by this is that eventually, every application needs refactoring. Refactoring a relatively big software, for example, with over 70,000 lines of code, can take 30-40 hours and also create a lot of errors and bugs in the process.

You might end up breaking the application or spending 20 more hours testing it. Also, when you reach a size this large, the refactoring won’t be as good as if you did it in the beginning.

What I propose is to spend your first 40-50 hours planning and refactoring. Just create a few controllers, brainstorm how that would scale in the future, refactor, and then continue.

Yes, your manager might scream in the beginning because you spent 50 hours coding and have almost nothing to showcase except a good architecture that will scale that no-one understand. But if you have the option to do it, then do it. It will save a ton of headaches for not only you but future developers as well.

Unit testing is also really important. Don’t be crazy with it just have at least 60% coverage and you are good to go. It will save you tones of bugs in the future.

Pre-commit Checks

This is very important. For JavaScript/TypeScript projects, we have Husky, but there are many alternatives for every language or framework out there. Husky is a tool that runs some commands before you commit your code. If got throw an error, the commit won't pass. Here is an example .husky configuration from reporanger.xyz.

  1. Lint Check

  2. Run Tests

  3. Prettify

This 3 simple steps will make your codebase. 10X better.

#!/bin/sh
npx eslint --max-warnings=0 src api/src || {
  echo "ESLint check failed. Commit aborted."
  exit 1
}

cd ./api && npx jest || {
  echo "Tests failed. Commit aborted."
  exit 1
}

cd .. && npx prettier --write .
git update-index --again

In a nutshell

There are many things you could do to improve your code. But the things that I mentioned are not even hard to implement. Especially now with LLM’s the problem is that you are bored to do them not that you can’t.

Throw boredom out of the window and you will get many blessings. Start coding with love and not for money. If you do that, you will make more money, make your co-workers happy and your managers will thank you.

Because Coding is not writing, it is Αrchitecture.

Thanks for reading, and I hope you found this article helpful. If you have any questions, feel free to email me at x@sotergreco.com, and I will respond.

You can also keep up with my latest updates by checking out my X here: x.com/sotergreco