Dependency Injection (DI) trong lập trình

Spring Boot : Dependency Injection (DI) Chìa khoá để code xịn và dễ bảo trì

1. Khái niệm Dependency Injection

Dependency Injection (DI) là một kỹ thuật thiết kế phần mềm cho phép một đối tượng nhận các thành phần mà nó phụ thuộc vào từ bên ngoài, thay vì tự tạo ra những thành phần đó bên trong chính nó.

Trong lập trình, một lớp thường không hoạt động độc lập mà cần sử dụng những lớp khác để thực hiện nhiệm vụ. Ví dụ, một lớp xử lý đơn hàng có thể cần một lớp truy cập cơ sở dữ liệu, một dịch vụ gửi email hoặc một dịch vụ thanh toán. Những thành phần mà lớp đó cần sử dụng được gọi là Dependency (sự phụ thuộc).

Dependency Injection giúp quản lý các mối quan hệ phụ thuộc này một cách rõ ràng, linh hoạt và dễ kiểm soát hơn. Thay vì để một lớp tự khởi tạo tất cả đối tượng cần thiết, hệ thống sẽ cung cấp những đối tượng đó thông qua constructor, thuộc tính hoặc phương thức.

DI là một kỹ thuật thường được sử dụng trong các ứng dụng có kiến trúc nhiều tầng, ứng dụng web, ứng dụng di động và các hệ thống phần mềm lớn. Kỹ thuật này xuất hiện trong nhiều công nghệ như Flutter, ASP.NET Core, Spring Boot, Angular và NestJS.

Mục tiêu chính của Dependency Injection là giảm sự phụ thuộc trực tiếp giữa các lớp, tăng khả năng tái sử dụng mã nguồn, hỗ trợ kiểm thử và giúp ứng dụng dễ dàng mở rộng khi yêu cầu thay đổi.

2. Lý thuyết và cơ chế hoạt động của Dependency Injection

2.1. Dependency trong lập trình hướng đối tượng

Dependency là một thành phần mà một lớp cần sử dụng để hoàn thành công việc của mình. Thành phần đó có thể là một đối tượng, một dịch vụ, một giao diện hoặc một thành phần xử lý dữ liệu.

Ví dụ, trong hệ thống bán hàng trực tuyến, lớp OrderService chịu trách nhiệm xử lý đơn hàng. Để lưu đơn hàng, lớp này cần một thành phần có khả năng truy cập cơ sở dữ liệu.

Mối quan hệ giữa hai lớp có thể được mô tả như sau:

  • OrderService: xử lý nghiệp vụ đơn hàng.

  • DatabaseService: thực hiện thao tác với cơ sở dữ liệu.

  • OrderService phụ thuộc vào DatabaseService để lưu thông tin đơn hàng.

Nếu OrderService tự tạo đối tượng DatabaseService bên trong, nó sẽ gắn chặt với cách triển khai cụ thể của thành phần cơ sở dữ liệu. Khi muốn chuyển từ cơ sở dữ liệu này sang cơ sở dữ liệu khác, lập trình viên có thể phải chỉnh sửa trực tiếp lớp xử lý đơn hàng.

DI giải quyết vấn đề này bằng cách tách việc tạo đối tượng khỏi việc sử dụng đối tượng.

2.2. Cơ chế hoạt động cơ bản

Dependency Injection thường hoạt động thông qua ba thành phần chính:

  1. Client: Đối tượng cần sử dụng một dịch vụ hoặc thành phần khác.

  2. Dependency: Đối tượng hoặc dịch vụ được cung cấp cho client.

  3. Injector hoặc Composition Root: Thành phần chịu trách nhiệm khởi tạo và kết nối các đối tượng cần thiết.

Quy trình hoạt động thường diễn ra theo các bước:

Bước 1: Xác định dependency

Lớp nghiệp vụ khai báo những thành phần mà nó cần sử dụng.

Bước 2: Khởi tạo các đối tượng

Hệ thống hoặc thành phần quản lý DI tạo các đối tượng cần thiết.

Bước 3: Inject dependency

Các đối tượng được truyền vào client thông qua constructor, thuộc tính hoặc phương thức.

Bước 4: Thực thi nghiệp vụ

Client sử dụng dependency được cung cấp mà không cần tự tạo đối tượng đó.

Ví dụ, khi người dùng đặt hàng, hệ thống có thể tạo một đối tượng OrderRepository, sau đó truyền đối tượng này vào OrderService. Khi cần lưu đơn hàng, OrderService gọi phương thức của repository để thực hiện công việc.

Điểm quan trọng là OrderService tập trung vào nghiệp vụ xử lý đơn hàng, còn việc khởi tạo và lựa chọn thành phần lưu trữ được quản lý ở một nơi khác.

2.3. So sánh cách viết thông thường và Dependency Injection

Cách viết không sử dụng DI:

class EmailService {
  void sendEmail(String message) {
    print('Sending email: $message');
  }
}

class OrderService {
  final EmailService emailService = EmailService();

  void createOrder() {
    print('Creating order...');
    emailService.sendEmail('Order created');
  }
}

Trong ví dụ trên, OrderService trực tiếp tạo đối tượng EmailService bằng cách sử dụng EmailService(). Điều này khiến OrderService phụ thuộc vào cách khởi tạo cụ thể của EmailService.

Nếu muốn thay đổi sang dịch vụ gửi thông báo khác, chẳng hạn SmsService, lập trình viên phải chỉnh sửa lớp OrderService.

Cách viết sử dụng Dependency Injection:

class EmailService {
  void sendEmail(String message) {
    print('Sending email: $message');
  }
}

class OrderService {
  final EmailService emailService;

  OrderService(this.emailService);

  void createOrder() {
    print('Creating order...');
    emailService.sendEmail('Order created');
  }
}

void main() {
  final emailService = EmailService();
  final orderService = OrderService(emailService);

  orderService.createOrder();
}

Ở ví dụ này, OrderService không còn tự tạo EmailService. Thay vào đó, đối tượng EmailService được tạo bên ngoài rồi truyền vào thông qua constructor.

Lợi ích của cách viết này là việc khởi tạo đối tượng được tách biệt khỏi logic nghiệp vụ. Lập trình viên có thể thay đổi đối tượng được cung cấp mà không cần thay đổi cách OrderService tiếp nhận dependency.

Đây là ví dụ về Constructor Injection, hình thức DI phổ biến và dễ áp dụng trong nhiều ngôn ngữ lập trình.

3. Các hình thức Dependency Injection

Có ba hình thức Dependency Injection thường gặp trong lập trình hướng đối tượng: Constructor Injection, Setter Injection và Method Injection.

3.1. Constructor Injection

Constructor Injection là phương pháp truyền dependency thông qua hàm khởi tạo của lớp.

Các dependency được khai báo dưới dạng tham số của constructor. Khi tạo đối tượng, lập trình viên phải cung cấp những thành phần cần thiết để đối tượng hoạt động.

Ví dụ bằng Dart:

abstract class PaymentService {
  void pay(double amount);
}

class MomoPaymentService implements PaymentService {
  @override
  void pay(double amount) {
    print('Thanh toán MoMo: $amount');
  }
}

class OrderService {
  final PaymentService paymentService;

  OrderService(this.paymentService);

  void checkout(double amount) {
    paymentService.pay(amount);
  }
}

void main() {
  final payment = MomoPaymentService();
  final order = OrderService(payment);

  order.checkout(500000);
}

Trong ví dụ này:

  • PaymentService là giao diện mô tả chức năng thanh toán.

  • MomoPaymentService là lớp triển khai chức năng thanh toán MoMo.

  • OrderService tiếp nhận một đối tượng thuộc kiểu PaymentService.

  • Hàm main() chịu trách nhiệm tạo đối tượng và truyền dependency vào hệ thống.

Constructor Injection phù hợp với các dependency bắt buộc phải có để đối tượng hoạt động chính xác.

Ưu điểm của phương pháp này là dependency được xác định ngay khi khởi tạo đối tượng, giúp hạn chế tình trạng đối tượng tồn tại nhưng chưa được cấu hình đầy đủ.

3.2. Setter Injection

Setter Injection là phương pháp truyền dependency thông qua một thuộc tính hoặc phương thức thiết lập sau khi đối tượng đã được tạo.

Phương pháp này phù hợp với các dependency không bắt buộc ngay tại thời điểm khởi tạo hoặc có thể thay đổi trong quá trình hoạt động.

Ví dụ:

class Logger {
  void log(String message) {
    print('[LOG] $message');
  }
}

class UserService {
  Logger? logger;

  void setLogger(Logger logger) {
    this.logger = logger;
  }

  void createUser() {
    print('Creating user...');

    logger?.log('User created successfully');
  }
}

void main() {
  final userService = UserService();

  userService.setLogger(Logger());
  userService.createUser();
}

Ở đây, UserService có thể được khởi tạo mà chưa cần Logger. Sau đó, thông qua phương thức setLogger(), hệ thống cung cấp dependency khi cần thiết.

Tuy nhiên, nếu Logger là thành phần bắt buộc thì cách viết này có thể tạo ra rủi ro: lập trình viên quên gọi setLogger(), khiến chức năng ghi nhật ký không hoạt động như dự kiến.

Vì vậy, Setter Injection nên được sử dụng có chủ đích, đặc biệt khi dependency thực sự có tính tùy chọn.

3.3. Method Injection

Method Injection là phương pháp truyền dependency trực tiếp vào một phương thức tại thời điểm gọi phương thức đó.

Thay vì lưu dependency trong thuộc tính của lớp, phương thức nhận dependency làm tham số và sử dụng trong phạm vi xử lý công việc.

Ví dụ:

class NotificationService {
  void send(String message) {
    print('Notification: $message');
  }
}

class OrderService {
  void completeOrder(
    NotificationService notificationService,
  ) {
    print('Order completed');

    notificationService.send(
      'Your order has been completed',
    );
  }
}

void main() {
  final orderService = OrderService();
  final notificationService = NotificationService();

  orderService.completeOrder(notificationService);
}

Trong trường hợp này, NotificationService chỉ được truyền vào khi thực hiện completeOrder(). Đối tượng OrderService không cần lưu trữ dịch vụ thông báo như một thuộc tính.

Method Injection phù hợp khi một dependency chỉ được sử dụng trong một thao tác cụ thể hoặc khi mỗi lần gọi phương thức có thể cần một đối tượng khác nhau.

3.4. So sánh các hình thức DI

Tiêu chí Constructor Injection Setter Injection Method Injection
Cách truyền Qua constructor Qua setter hoặc thuộc tính Qua tham số phương thức
Thời điểm Khi tạo đối tượng Sau khi tạo đối tượng Khi gọi phương thức
Dependency bắt buộc Rất phù hợp Không phải lựa chọn ưu tiên Phù hợp cho thao tác riêng
Nguy cơ thiếu dependency Thấp nếu kiểu dữ liệu được khai báo phù hợp Có thể xảy ra Có thể hạn chế ở từng lần gọi
Trường hợp sử dụng Service, Repository, API client Thành phần tùy chọn Tác vụ cụ thể

Trong phần lớn trường hợp, Constructor Injection là lựa chọn nên ưu tiên vì thể hiện rõ các dependency cần thiết của một lớp ngay từ thời điểm khởi tạo.

4. Dependency Injection và nguyên tắc Dependency Inversion

Dependency Injection có mối liên hệ chặt chẽ với nguyên tắc Dependency Inversion Principle (DIP), một trong năm nguyên tắc thuộc bộ SOLID.

DIP quy định rằng các module cấp cao không nên phụ thuộc trực tiếp vào các module cấp thấp. Thay vào đó, cả hai nên phụ thuộc vào abstraction, tức các thành phần trừu tượng như interface hoặc abstract class.

Để hiểu rõ hơn, có thể xét một hệ thống thanh toán trong ứng dụng thương mại điện tử.

Nếu OrderService phụ thuộc trực tiếp vào MomoPaymentService, hệ thống sẽ khó mở rộng khi cần bổ sung thanh toán qua ngân hàng, thẻ tín dụng hoặc ví điện tử khác.

Một thiết kế linh hoạt hơn là tạo giao diện PaymentService. Các phương thức thanh toán cụ thể sẽ triển khai giao diện này, trong khi OrderService chỉ làm việc với giao diện chung.

Ví dụ:

abstract class PaymentService {
  void pay(double amount);
}

class MomoPaymentService implements PaymentService {
  @override
  void pay(double amount) {
    print('Thanh toán qua MoMo: $amount');
  }
}

class BankPaymentService implements PaymentService {
  @override
  void pay(double amount) {
    print('Thanh toán qua ngân hàng: $amount');
  }
}

class OrderService {
  final PaymentService paymentService;

  OrderService(this.paymentService);

  void checkout(double amount) {
    paymentService.pay(amount);
  }
}

void main() {
  final momo = OrderService(MomoPaymentService());
  momo.checkout(200000);

  final bank = OrderService(BankPaymentService());
  bank.checkout(300000);
}

Với thiết kế này, OrderService không cần biết chi tiết cách MoMo hoặc ngân hàng thực hiện thanh toán. Nó chỉ biết rằng dependency được cung cấp có khả năng thực hiện phương thức pay().

Cần phân biệt hai khái niệm:

  • Dependency Inversion Principle: Nguyên tắc thiết kế nhằm giảm sự phụ thuộc giữa các module thông qua abstraction.

  • Dependency Injection: Kỹ thuật cung cấp dependency từ bên ngoài vào một đối tượng.

DI hỗ trợ triển khai DIP hiệu quả, nhưng hai khái niệm không đồng nghĩa với nhau. Có thể sử dụng DI mà chưa áp dụng DIP đầy đủ nếu lớp nghiệp vụ vẫn phụ thuộc vào các triển khai cụ thể.

5. Dependency Injection Container

Trong các ứng dụng nhỏ, lập trình viên có thể tự tạo đối tượng và truyền dependency bằng tay. Tuy nhiên, khi hệ thống có hàng chục hoặc hàng trăm service, repository và API client, việc quản lý thủ công trở nên phức tạp.

Dependency Injection Container là thành phần quản lý quá trình đăng ký, khởi tạo và cung cấp dependency cho các thành phần khác trong ứng dụng.

Container thường thực hiện những nhiệm vụ như:

  • Đăng ký các lớp hoặc dịch vụ cần quản lý.

  • Xác định cách tạo từng đối tượng.

  • Giải quyết chuỗi dependency giữa nhiều lớp.

  • Quản lý vòng đời của đối tượng.

  • Cung cấp đúng dependency khi một thành phần được yêu cầu.

Ví dụ, một ứng dụng bán hàng có cấu trúc phụ thuộc như sau:

OrderService (Xử lý đơn hàng) -> OrderRepository (Truy cập dữ liệu đơn hàng) -> DatabaseClient (Kết nối cơ sở dữ liệu)

DI Container có thể quản lý quá trình tạo và kết nối các đối tượng trong chuỗi phụ thuộc này.

Khi cần khởi tạo OrderService, container có thể tạo DatabaseClient, cung cấp đối tượng đó cho OrderRepository, sau đó truyền repository vào OrderService.

Nhờ vậy, lập trình viên không cần tự viết toàn bộ quy trình khởi tạo mỗi khi muốn sử dụng dịch vụ.

Tuy nhiên, DI không bắt buộc phải có container. Việc truyền đối tượng bằng tay như OrderService(repository) cũng đã là Dependency Injection. Container chỉ giúp tự động hóa và quản lý quá trình này trong những hệ thống phức tạp.

6. Vòng đời của dependency

Một vấn đề quan trọng khi sử dụng DI là quản lý vòng đời của đối tượng. Không phải dependency nào cũng cần được tạo mới mỗi lần sử dụng.

Tùy thuộc vào framework và container, dependency có thể được quản lý theo nhiều vòng đời khác nhau.

6.1. Transient

Transient là cách quản lý trong đó mỗi lần yêu cầu dependency, hệ thống tạo một đối tượng mới.

Ví dụ, nếu một service được đăng ký theo vòng đời transient và được yêu cầu ba lần, hệ thống có thể tạo ra ba instance riêng biệt.

Đặc điểm:

  • Mỗi lần yêu cầu thường nhận được một instance mới.

  • Phù hợp với các đối tượng nhẹ, ít hoặc không lưu giữ trạng thái.

  • Có thể làm tăng số lượng đối tượng được tạo nếu sử dụng không hợp lý.

6.2. Singleton

Singleton là cách quản lý trong đó một instance được tạo và tái sử dụng trong phạm vi quản lý được quy định.

Ví dụ, ứng dụng có thể dùng chung một cấu hình hệ thống hoặc một thành phần quản lý kết nối trong phạm vi thích hợp.

Đặc điểm:

  • Giảm việc tạo nhiều đối tượng tương tự nhau.

  • Có thể chia sẻ trạng thái giữa các thành phần.

  • Cần chú ý đến dữ liệu dùng chung, tính an toàn khi chạy đồng thời và thời điểm giải phóng tài nguyên.

Singleton do DI container quản lý không hoàn toàn giống với việc triển khai mẫu thiết kế Singleton bằng constructor riêng tư hoặc biến static. Một đối tượng chỉ được dùng chung trong phạm vi đăng ký của container chưa chắc là singleton toàn cục cho cả ứng dụng.

6.3. Scoped

Scoped là vòng đời trong đó một instance được chia sẻ trong một phạm vi xác định. Phạm vi này phụ thuộc vào framework, chẳng hạn một HTTP request hoặc một phạm vi xử lý cụ thể.

Ví dụ, trong ứng dụng web, một dịch vụ xử lý thông tin người dùng có thể được dùng chung trong cùng một request nhưng tạo instance khác cho request tiếp theo.

Đặc điểm:

  • Tránh tạo nhiều instance không cần thiết trong cùng phạm vi.

  • Giúp quản lý dữ liệu và tài nguyên theo từng phạm vi.

  • Cần bảo đảm dependency scoped không bị sử dụng sai phạm vi, chẳng hạn bị giữ lại lâu dài trong một singleton.

6.4. Bảng so sánh vòng đời

Vòng đời Cách hoạt động Trường hợp phù hợp
Transient Tạo instance mới mỗi lần yêu cầu Đối tượng ngắn hạn, ít trạng thái
Singleton Dùng chung instance trong phạm vi đăng ký Cấu hình, dịch vụ dùng chung phù hợp
Scoped Dùng chung trong một phạm vi xác định Dịch vụ theo request hoặc tác vụ

Các tên gọi và quy tắc cụ thể có thể khác nhau giữa những framework. Khi sử dụng DI trong một công nghệ cụ thể, cần kiểm tra cách framework quản lý vòng đời và giải phóng tài nguyên.

7. Ứng dụng Dependency Injection trong Flutter và Dart

Trong Flutter, Dependency Injection giúp tách biệt giao diện, xử lý nghiệp vụ và truy cập dữ liệu. Cách tổ chức này đặc biệt hữu ích khi ứng dụng có nhiều màn hình, API, cơ sở dữ liệu cục bộ và các lớp quản lý trạng thái.

Một kiến trúc cơ bản có thể chia thành các thành phần:

  • UI Layer: Hiển thị giao diện và tiếp nhận thao tác của người dùng.

  • Service Layer: Xử lý nghiệp vụ, gọi API hoặc thực hiện các tác vụ ứng dụng.

  • Repository Layer: Quản lý việc đọc và ghi dữ liệu.

  • Data Source: Làm việc với API, cơ sở dữ liệu hoặc bộ nhớ cục bộ.

  • DI Container: Đăng ký và cung cấp các đối tượng cần thiết.

Ví dụ với một ứng dụng Flutter hiển thị danh sách sản phẩm.

class Product {
  final int id;
  final String name;

  Product({
    required this.id,
    required this.name,
  });
}

abstract class ProductRepository {
  List<Product> getProducts();
}

class MockProductRepository implements ProductRepository {
  @override
  List<Product> getProducts() {
    return [
      Product(id: 1, name: 'Laptop'),
      Product(id: 2, name: 'Smartphone'),
      Product(id: 3, name: 'Headphones'),
    ];
  }
}

class ProductService {
  final ProductRepository repository;

  ProductService(this.repository);

  List<Product> getProducts() {
    return repository.getProducts();
  }
}

void main() {
  final repository = MockProductRepository();
  final productService = ProductService(repository);

  final products = productService.getProducts();

  for (final product in products) {
    print('${product.id}: ${product.name}');
  }
}

Trong ví dụ trên, ProductService không trực tiếp tạo MockProductRepository. Repository được truyền từ bên ngoài, giúp lớp nghiệp vụ không bị ràng buộc với một nguồn dữ liệu cụ thể.

Khi muốn lấy dữ liệu từ API thật, lập trình viên có thể tạo một lớp khác triển khai ProductRepository, chẳng hạn ApiProductRepository, rồi truyền đối tượng đó vào ProductService.

Phần xử lý nghiệp vụ có thể được giữ nguyên, miễn là repository mới đáp ứng đúng giao diện đã định nghĩa.

7.1. Sử dụng package get_it

get_it là một package phổ biến trong hệ sinh thái Dart và Flutter, cung cấp cơ chế Service Locator để đăng ký và truy xuất các đối tượng dùng chung.

Ví dụ:

import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

abstract class ApiService {
  void fetchData();
}

class ApiServiceImpl implements ApiService {
  @override
  void fetchData() {
    print('Fetching data from API...');
  }
}

class UserRepository {
  final ApiService apiService;

  UserRepository(this.apiService);

  void getUsers() {
    apiService.fetchData();
  }
}

void setupDependencies() {
  getIt.registerLazySingleton<ApiService>(
    () => ApiServiceImpl(),
  );

  getIt.registerLazySingleton<UserRepository>(
    () => UserRepository(getIt<ApiService>()),
  );
}

void main() {
  setupDependencies();

  final repository = getIt<UserRepository>();
  repository.getUsers();
}

Trong ví dụ này:

  • GetIt.instance tạo điểm truy cập đến bộ quản lý dependency.

  • registerLazySingleton() đăng ký một dependency và chỉ tạo instance khi nó được yêu cầu lần đầu.

  • getIt<ApiService>() truy xuất đối tượng đã đăng ký theo kiểu ApiService.

  • UserRepository nhận ApiService thông qua constructor.

Cần lưu ý rằng get_it thường được phân loại là Service Locator. Nó cung cấp cơ chế truy xuất dependency từ một nơi đăng ký chung, trong khi Constructor Injection thuần túy truyền dependency trực tiếp qua tham số constructor. Hai cách có thể kết hợp trong một ứng dụng nhưng không hoàn toàn giống nhau.

Trong dự án Flutter, có thể đăng ký dependency tại điểm khởi tạo ứng dụng, sau đó cung cấp các service hoặc repository cho những thành phần cần sử dụng.

7.2. Kết hợp Dependency Injection với Provider

Provider là một package thường được dùng trong Flutter để cung cấp và chia sẻ đối tượng trong cây widget. Nó có thể hỗ trợ đưa các service hoặc đối tượng quản lý trạng thái đến những widget ở phía dưới.

Ví dụ:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class CounterService extends ChangeNotifier {
  int count = 0;

  void increment() {
    count++;
    notifyListeners();
  }
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CounterService(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Dependency Injection'),
        ),
        body: Center(
          child: Consumer<CounterService>(
            builder: (context, service, child) {
              return Text(
                'Count: ${service.count}',
                style: const TextStyle(fontSize: 24),
              );
            },
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            context.read<CounterService>().increment();
          },
          child: const Icon(Icons.add),
        ),
      ),
    );
  }
}

Trong ví dụ này, ChangeNotifierProvider tạo và cung cấp CounterService cho các widget con. Consumer nhận đối tượng để hiển thị giá trị, còn context.read<CounterService>() cho phép gọi phương thức tăng biến đếm mà không đăng ký lắng nghe thay đổi tại vị trí gọi đó.

Provider vừa hỗ trợ cung cấp đối tượng, vừa hỗ trợ quản lý và thông báo thay đổi trạng thái trong phạm vi widget. Tuy nhiên, DI và State Management là hai vấn đề khác nhau: DI quản lý việc cung cấp dependency, còn State Management quản lý trạng thái và cách giao diện phản ứng với những thay đổi đó.

8. Lợi ích của Dependency Injection

8.1. Giảm sự phụ thuộc giữa các lớp

DI giúp một lớp không phải trực tiếp tạo và quản lý toàn bộ các đối tượng mà nó sử dụng. Khi phụ thuộc vào abstraction, lớp nghiệp vụ có thể làm việc với nhiều cách triển khai khác nhau mà không cần thay đổi logic chính.

Điều này đặc biệt quan trọng trong các ứng dụng có nhiều module và nhiều thành viên cùng phát triển.

8.2. Tăng khả năng tái sử dụng mã nguồn

Khi một lớp không gắn chặt với một dependency cụ thể, nó có thể được tái sử dụng trong nhiều môi trường khác nhau.

Ví dụ, ProductService có thể sử dụng repository lấy dữ liệu từ API thật, dữ liệu giả lập hoặc cơ sở dữ liệu cục bộ. Phần nghiệp vụ không cần viết lại cho từng nguồn dữ liệu nếu tất cả cùng đáp ứng một abstraction.

8.3. Hỗ trợ kiểm thử phần mềm

DI giúp việc kiểm thử trở nên dễ dàng hơn vì có thể truyền dependency giả lập vào lớp đang kiểm thử.

Ví dụ:

abstract class UserRepository {
  String getUserName();
}

class FakeUserRepository implements UserRepository {
  @override
  String getUserName() {
    return 'Test User';
  }
}

class UserService {
  final UserRepository repository;

  UserService(this.repository);

  String getUserName() {
    return repository.getUserName();
  }
}

void main() {
  final service = UserService(FakeUserRepository());

  assert(service.getUserName() == 'Test User');
}

Ở đây, FakeUserRepository cung cấp dữ liệu giả lập thay vì truy cập cơ sở dữ liệu thật. Nhờ vậy, lập trình viên có thể kiểm tra logic của UserService mà không cần thiết lập máy chủ hoặc dữ liệu bên ngoài.

Trong các dự án lớn, có thể dùng mock hoặc fake để kiểm tra nhiều trường hợp khác nhau, bao gồm dữ liệu hợp lệ, dữ liệu rỗng và các tình huống lỗi.

8.4. Hỗ trợ mở rộng hệ thống

Khi yêu cầu thay đổi, lập trình viên có thể bổ sung triển khai mới mà ít ảnh hưởng đến các lớp hiện có.

Ví dụ, một hệ thống thương mại điện tử ban đầu chỉ hỗ trợ thanh toán MoMo. Sau đó, hệ thống cần bổ sung thanh toán qua ngân hàng và thẻ tín dụng. Nếu kiến trúc được thiết kế dựa trên PaymentService, các phương thức mới có thể triển khai cùng giao diện và được cung cấp thông qua DI.

8.5. Tập trung quản lý quá trình khởi tạo

Trong những ứng dụng có nhiều dependency, việc đăng ký và khởi tạo đối tượng tại một vị trí rõ ràng giúp lập trình viên dễ kiểm soát cấu trúc hệ thống.

Thay vì để mỗi lớp tự khởi tạo hàng loạt thành phần khác, hệ thống có thể quản lý việc kết nối các đối tượng tại composition root hoặc thông qua DI container.

8.6. Thuận lợi cho làm việc nhóm

Trong một dự án có nhiều thành viên, mỗi người có thể phát triển một module dựa trên các interface đã thống nhất.

Ví dụ, thành viên phụ trách frontend có thể làm việc với repository giả lập, trong khi thành viên phụ trách backend phát triển repository kết nối API. Khi các thành phần đáp ứng cùng hợp đồng, nhóm có thể tích hợp chúng với ít thay đổi hơn.

9. Hạn chế và những vấn đề cần lưu ý

Mặc dù DI mang lại nhiều lợi ích, kỹ thuật này cũng có những hạn chế nếu sử dụng không hợp lý.

Tăng độ phức tạp ban đầu: Với ứng dụng nhỏ chỉ có một vài lớp, việc tạo nhiều interface, container và cấu hình có thể làm mã nguồn dài hơn cần thiết.

Khó theo dõi luồng khởi tạo: Khi sử dụng container và đăng ký dependency ở nhiều nơi, lập trình viên mới có thể khó xác định một đối tượng được tạo ở đâu và phụ thuộc vào thành phần nào.

Lỗi cấu hình dependency: Nếu quên đăng ký một dependency hoặc đăng ký sai kiểu, ứng dụng có thể phát sinh lỗi khi khởi tạo hoặc truy xuất đối tượng.

Quản lý vòng đời không phù hợp: Việc dùng chung một đối tượng chứa trạng thái có thể gây ra dữ liệu không mong muốn giữa các request hoặc người dùng. Ngược lại, tạo mới các đối tượng nặng quá thường xuyên có thể lãng phí tài nguyên.

Lạm dụng abstraction: Không phải lớp nào cũng cần một interface riêng. Việc tạo quá nhiều lớp trung gian mà không mang lại lợi ích thực tế sẽ khiến dự án khó hiểu và khó bảo trì hơn.

Để hạn chế những vấn đề trên, cần lựa chọn mức độ DI phù hợp với quy mô dự án, thống nhất cách đăng ký dependency và xác định rõ vòng đời của từng đối tượng.

10. Nguyên tắc sử dụng Dependency Injection hiệu quả

10.1. Ưu tiên Constructor Injection cho dependency bắt buộc

Nếu một lớp không thể hoạt động khi thiếu một dependency, nên truyền dependency qua constructor. Cách này giúp thể hiện rõ các yêu cầu của lớp và hạn chế trạng thái khởi tạo chưa hoàn chỉnh.

10.2. Phụ thuộc vào abstraction khi cần tính linh hoạt

Đối với các thành phần có khả năng thay đổi cách triển khai, chẳng hạn dịch vụ thanh toán, repository hoặc API client, nên cân nhắc sử dụng interface hoặc abstract class.

Tuy nhiên, không cần tạo abstraction cho mọi lớp nếu không có nhu cầu thay thế, kiểm thử hoặc tách biệt trách nhiệm rõ ràng.

10.3. Tách biệt khởi tạo đối tượng và xử lý nghiệp vụ

Lớp nghiệp vụ nên tập trung vào nhiệm vụ chính thay vì trực tiếp tạo nhiều đối tượng phụ thuộc. Việc khởi tạo có thể được đặt tại composition root hoặc quản lý bằng container.

10.4. Quản lý vòng đời dependency rõ ràng

Cần xác định đối tượng nào nên được tạo mới, đối tượng nào có thể dùng chung và đối tượng nào phải được giải phóng khi hoàn thành công việc.

Đặc biệt, với các thành phần kết nối cơ sở dữ liệu, stream, controller hoặc tài nguyên hệ thống, cần tuân thủ quy tắc quản lý và giải phóng tài nguyên của framework tương ứng.

10.5. Hạn chế phụ thuộc vào Service Locator ở mọi nơi

Việc gọi container toàn cục từ bất kỳ lớp nào có thể khiến dependency thực sự của lớp trở nên khó nhận biết. Với các thành phần nghiệp vụ cốt lõi, truyền dependency rõ ràng qua constructor thường giúp mã nguồn dễ hiểu và dễ kiểm thử hơn.

10.6. Kiểm thử độc lập từng thành phần

Nên tận dụng khả năng thay thế dependency để kiểm thử từng module. Các bài kiểm thử cần xác nhận kết quả nghiệp vụ, đồng thời kiểm tra cách hệ thống phản ứng khi dependency trả về dữ liệu không hợp lệ hoặc phát sinh lỗi.

10.7. Giữ cấu trúc dự án đơn giản

DI nên giải quyết vấn đề thực tế của dự án, không phải trở thành một tầng cấu hình phức tạp không cần thiết. Với dự án nhỏ, truyền đối tượng thủ công có thể đã đủ. Với dự án lớn, container và các công cụ hỗ trợ sẽ hữu ích hơn.

11. Các khái niệm liên quan đến Dependency Injection

Khái niệm Ý nghĩa
Dependency Thành phần mà một lớp cần sử dụng
Injection Quá trình cung cấp dependency cho một đối tượng
Interface Hợp đồng mô tả các hành vi mà lớp triển khai cần đáp ứng
Abstract Class Lớp trừu tượng dùng để định nghĩa cấu trúc và hành vi chung
IoC (Inversion of Control) Nguyên tắc chuyển quyền kiểm soát một phần quá trình khởi tạo hoặc điều phối ra bên ngoài đối tượng
DI Container Thành phần đăng ký, khởi tạo và cung cấp dependency
Composition Root Vị trí tập trung kết nối các thành phần của ứng dụng
Service Locator Cơ chế truy xuất dependency thông qua một bộ đăng ký chung
Unit Testing Kiểm thử một đơn vị chức năng độc lập
Mock/Fake Các đối tượng thay thế được dùng để kiểm thử
DIP Nguyên tắc Dependency Inversion thuộc SOLID
Singleton Mẫu thiết kế hoặc cách quản lý một instance dùng chung trong phạm vi xác định

DI là một cách triển khai phổ biến của IoC, nhưng IoC rộng hơn DI. IoC đề cập đến việc chuyển quyền kiểm soát ra khỏi đối tượng, trong khi DI tập trung vào việc cung cấp các dependency từ bên ngoài.

Tương tự, Service Locator và DI Container có thể cùng tồn tại trong một hệ thống, nhưng cách truy xuất dependency của chúng khác nhau. Phân biệt các khái niệm này giúp lập trình viên lựa chọn kiến trúc phù hợp và tránh nhầm lẫn khi làm việc với framework.

12. Kết luận

Dependency Injection là một kỹ thuật thiết kế quan trọng trong lập trình hướng đối tượng, giúp quản lý mối quan hệ phụ thuộc giữa các thành phần một cách linh hoạt và rõ ràng. Thay vì để một lớp tự tạo các đối tượng mà nó cần, DI cho phép những đối tượng đó được cung cấp từ bên ngoài thông qua constructor, setter hoặc phương thức.

Việc áp dụng DI hợp lý giúp giảm sự phụ thuộc trực tiếp giữa các lớp, tăng khả năng tái sử dụng mã nguồn, hỗ trợ kiểm thử độc lập và tạo điều kiện mở rộng hệ thống khi yêu cầu thay đổi. Khi kết hợp với các nguyên tắc SOLID, đặc biệt là Dependency Inversion Principle, DI góp phần xây dựng kiến trúc phần mềm có tính mô-đun cao và dễ bảo trì.

Trong Flutter và Dart, DI có thể được triển khai bằng cách truyền đối tượng trực tiếp qua constructor hoặc sử dụng những công cụ như get_it và Provider để quản lý, cung cấp các dependency trong ứng dụng. Việc lựa chọn phương pháp phụ thuộc vào quy mô dự án, đặc điểm kiến trúc và yêu cầu quản lý vòng đời đối tượng.

Tuy nhiên, DI không phải giải pháp cần áp dụng một cách máy móc cho mọi lớp. Lập trình viên cần xác định đúng các dependency, lựa chọn hình thức injection phù hợp và tránh tạo ra cấu trúc quá phức tạp. Khi được sử dụng đúng cách, Dependency Injection sẽ giúp mã nguồn dễ hiểu, dễ kiểm thử, dễ thay đổi và tạo nền tảng vững chắc cho quá trình phát triển phần mềm lâu dài.

Bài viết khác

Provider & Notifier trong Flutter

Provider & Notifier trong Flutter 1. Định nghĩa Provider và Notifier Trong Flutter, Provider và Notifier là hai khái niệm thường được sử dụng trong quản lý trạng thái (State Management). Chúng giúp tổ chức dữ liệu, xử lý logic nghiệp vụ và cập nhật giao diện khi dữ liệu của ứng dụng thay đổi. […]

Hive Local Storage

      Trong quá trình phát triển ứng dụng di động, việc lưu trữ dữ liệu cục bộ là một yêu cầu rất quan trọng. Không phải lúc nào ứng dụng cũng có thể kết nối liên tục với máy chủ hoặc cơ sở dữ liệu trực tuyến. Người dùng có thể cần mở […]

Forms & Input Validation

      Trong quá trình phát triển ứng dụng web, biểu mẫu hay form là một trong những phương tiện quan trọng nhất để người dùng giao tiếp với hệ thống. Từ đăng ký tài khoản, đăng nhập, tìm kiếm, đặt hàng cho đến gửi phản hồi, hầu hết các hoạt động trên website […]

Regression Testing(Kiểm thử hồi quy)

1.Regression Testing (Kiểm thử hồi quy) là gì? Regression Testing (Kiểm thử hồi quy) là quá trình chạy lại toàn bộ hoặc một phần các ca kiểm thử (test cases) đã có để đảm bảo rằng những thay đổi gần đây trong mã nguồn (như vá lỗi, thêm tính năng mới, tối ưu code hoặc […]

Riverpod, BLoC và Provider

Riverpod, BLoC và Provider trong Flutter 1. Tổng quan về các giải pháp quản lý trạng thái Trong quá trình phát triển ứng dụng Flutter, State Management là một trong những vấn đề quan trọng cần được giải quyết để đảm bảo dữ liệu được quản lý nhất quán và giao diện được cập nhật […]

State Management

State Management 1. Tổng quan về State Management State Management là kỹ thuật quản lý trạng thái của ứng dụng, đóng vai trò quan trọng trong quá trình phát triển giao diện người dùng bằng Flutter. Kỹ thuật này giúp ứng dụng lưu trữ, cập nhật, chia sẻ và đồng bộ dữ liệu giữa các […]

Leave a Reply

Your email address will not be published. Required fields are marked *