Things Java (and C#) developer should unlearn when start learning new languages (Part 1)
Chakrit Likitkhajorn
Posted on March 11, 2023
Java and C# are very famous for being good programming languages for enterprise work. It's pretty stable. Patterns and practices are known and well-established. They are good boring technology that works.
However, since the community and developer of these languages usually work in enterprise development. Enterprise love having a standard. Enterprise love having design patterns. Enterprise love having best practices. In Java and C# world, there are established patterns and best practices for almost everything. Many developers are rigidly thought that there is only one single best way to achieve something, and every alternative is undesirable. This belief is embedded in the culture of the community.
For the sake of future reference, I will call these developer JC developer.
That culture well in the enterprise environment. It's not good to have every single developer invent their own pattern and make every part of a big codebase become weirdly unique.
However, I found many JC developers struggle to learn a new programming language.
Other programming languages such as Go, Elixir, Python, Javascript, TypeScript, Clojure, Ruby, Scala or Rust have a different style, semantic and idiomatic which looks and feels different from JC. I've seen many
JC developers try to force JC-ish code into other programming languages and it usually does not end well.
There are many patterns, best practices and beliefs that JC developer should let go of while learning other programming language. I'm here to talk about it.
Dependency Injection (or IoC container) isn't always necessary to make code testable
This is the most common one. JC developer usually asked where is dependency injection and where is IoC container that I can config dependencies.
In many programming language, you don't need IoC container in order to switch between test double and actual code.
Assuming you have to call PaymentGateway
to pay for things. If the result is success, you need to save some data in response into transaction database.
In Ruby, you can just switch test and code using Mocha
. The language allow us to do a simple monkey patching. Here are some examples.
Ruby
class Pay
def pay(payment)
response = PaymentService.new.pay(payment)
transaction = Transaction.new
transaction.amount = response[:amount]
transaction.ref = response[:ref]
raise 'Cannot save' unless transaction.save
transaction
end
end
class PayTest < ActiveSupport::TestCase
test 'pay when success' do
payment = { amount: 500 }
Transaction.any_instance.stubs(:save).returns(true)
PaymentService.any_instance.expects(:pay).with(payment).returns({ amount: 500, ref: 'super' })
t = Pay.new.pay(payment)
assert_equal 500, t.amount
assert_equal 'super', t.ref
end
test 'raise when pay failed' do
Transaction.any_instance.stubs(:save).returns(false)
PaymentService.any_instance.stubs(:pay).returns({ amount: 500, ref: 'super' })
exception = assert_raise(StandardError) do
Pay.new.pay({ amount: 500 })
end
assert_equal 'Cannot save', exception.message
end
end
In Ruby, even though class Pay
require PaymentService
object to connect to payment gateway and Transaction
object to connect to database. We don't really need dependency injection or IoC container in order to make this code testable. Ruby allows us to simply say: For every transaction instance, save
will return true and PaymentService
will return our particular result without connecting to an actual third-party.
Transaction.any_instance.stubs(:save).returns(true)
PaymentService.any_instance.expects(:pay).with(payment).returns({ amount: 500, ref: 'super' })
Javascript/TypeScript also have same monkey-patch approach. But since class isn't even necessary, we can even reduce PaymentService
and Transaction
to be mere functions.
// Implementation
const transaction = require("./transaction");
const paymentService = require("./paymentService");
async function pay(payment) {
const response = await paymentService.pay(payment);
const newTransaction = await transaction.save({
amount: response.amount,
ref: response.ref,
});
if (!newTransaction) {
throw new Error("Cannot save");
}
return newTransaction;
}
// ----------------- Test file -----------------
jest.mock("./transaction");
jest.mock("./paymentService");
const transaction = require("./transaction");
const paymentService = require("./paymentService");
const pay = require("./pay");
describe("pay", () => {
it("should be able to pay with correct amount", async () => {
const payment = { amount: 300 };
paymentService.pay.mockResolvedValue({ amount: 300, ref: "myref" });
transaction.save.mockResolvedValue({ amount: 300, ref: "myref" });
const result = await pay.pay(payment);
expect(transaction.save).toHaveBeenCalledWith({
amount: 300,
ref: "myref",
});
expect(result.amount).toEqual(300);
expect(result.ref).toEqual("myref");
});
it("should be throw when cannot saved", async () => {
const payment = { amount: 300 };
paymentService.pay.mockResolvedValue({ amount: 300, ref: "myref" });
transaction.save.mockResolvedValue(null);
await expect(() => pay.pay(payment)).rejects.toThrowError("Cannot save");
});
});
First of all, in Javascript, you don't always need an object or class to do a simple thing (I will emphasize this later). Second, you can use jest.mock
to force return value and even implementation of a class or function. This means you don't need a dependency injection or IoC container to create a testable code.
While there might be some merit to using IoC and dependency injection anyway, I have seen many JC developers who insist that you can't write testable code without dependency injection and IoC container. That is not true for many other languages.
If all you need is testable code, mocking and monkey-patching (like jest.mock
and stub
) can be simpler and more direct to achieve the effect. You don't need to bring the whole IoC container library into the equation.
In Go, dependency injection in general is still a common way to write a test. However, since Go does not confine you to a class system, dependency injection can be done at a function level. Here is an example from gitbook Learn Go with test.
package main
import (
"fmt"
"io"
)
type Sleeper interface {
Sleep()
}
const finalWord = "Go!"
const countdownStart = 3
func Countdown(out io.Writer, sleeper Sleeper) {
for i := countdownStart; i > 0; i-- {
fmt.Fprintln(out, i)
sleeper.Sleep()
}
fmt.Fprint(out, finalWord)
}
// ----------------- Test -------------------
type SpySleeper struct {
Calls int
}
func (s *SpySleeper) Sleep() {
s.Calls++
}
func TestCountdown(t *testing.T) {
buffer := &bytes.Buffer{}
spySleeper := &SpySleeper{}
Countdown(buffer, spySleeper)
got := buffer.String()
want := `3
2
1
Go!`
if got != want {
t.Errorf("got %q want %q", got, want)
}
if spySleeper.Calls != 3 {
t.Errorf("not enough calls to sleeper, want 3 got %d", spySleeper.Calls)
}
}
Notice that the SpySleeper
is injected directly into the function. You can see that in other languages it isn't necessary to inject dependency via an object constructor.
I have demonstrated how you can write unit testable code via different method aside from what you usually do in JC world, a constructor-based dependency injection with IoC container in framework.
There are many ways to make code testable in other languages. Dependency injection isn't the only option and even with Go, dependency injection isn't necessarily about the constructor.
So the first thing I suggest JC developer unlearn is the fixation toward dependency injection and IoC container. A good code should be testable and it's true that in JC environment, you should use always use dependency injection to write good code. That's the only way in JC.
However, when you start learning other languages, you can write testable code without them.
When JC developer learn new language, I suggest to stay open-minded about testing approach. I know many were thought that "you must use DI and never instantiate anything directly because you need to be able to test your code". Again, it is 100% correct for JC but not necessary true in other languages.
I'm not saying that you should never use IoC container in another programming language. I'm saying that IoC container and constructor-based dependency injection is not the only one and true way to achieve testable code. JC developer would become much better programmer if they are open minded about this.
JC developer perspective can be greatly broadened if they let their enterprise one-and-true-best-practice go while learning a new programming language paradigm.
In the end, if they find that the IoC container is the best solution because of any other factor outside of testing, then please use it. Go ahead.
What I've seen is many JC developers throw all other approaches out of the window because it doesn't conform to what they learned as best practices. As a result, their perspective become narrow and they did not learn anything from a new programming language. Such a pity, I would say.
I have a mixed feeling in how many TypeScript framework adopt IoC container approach. I understand it helps JC developer to be at ease with TypeScript, but at the same time I always think it is unnecessary if all you want to do is just write a testable code.
Stay tuned for part 2: Everything don't need to be an object.
Posted on March 11, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.