CODE WITH SIBIN

Solving Real Problems with Real Code


Spring Boot 3 + Angular 17 + Material – Full Stack CRUD Application Example

Back End

Here's a complete Spring Boot implementation for the backend API that your Angular 17+ Material CRUD app can consume at http://localhost:8080/api.

Project Structure

1. Maven Dependencies (pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.0</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    
    <properties>
        <java.version>17</java.version>
    </properties>
    
    <dependencies>
        <!-- Spring Boot Starters -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- Database -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- Testing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

2. Application Properties (application.properties)

# Server
server.port=8080

# H2 Database
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# H2 Console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# JPA
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true

3. Entity Model (Product.java)

package com.example.demo.model;

import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;

@Entity
@Table(name = "products")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String description;

    @Column(nullable = false)
    private Double price;

    @CreationTimestamp
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @UpdateTimestamp
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

4. DTO (ProductDto.java)

package com.example.demo.model;

import lombok.*;

import java.time.LocalDateTime;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ProductDto {
    private Long id;
    private String name;
    private String description;
    private Double price;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

5. Repository (ProductRepository.java)

package com.example.demo.repository;

import com.example.demo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}

6. Service Interface (ProductService.java)

package com.example.demo.service;

import com.example.demo.model.ProductDto;
import java.util.List;

public interface ProductService {
    List<ProductDto> getAllProducts();
    ProductDto getProductById(Long id);
    ProductDto createProduct(ProductDto productDto);
    ProductDto updateProduct(Long id, ProductDto productDto);
    void deleteProduct(Long id);
}

7. Service Implementation (ProductServiceImpl.java)

package com.example.demo.service;

import com.example.demo.model.Product;
import com.example.demo.model.ProductDto;
import com.example.demo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {

    private final ProductRepository productRepository;

    @Override
    @Transactional(readOnly = true)
    public List<ProductDto> getAllProducts() {
        return productRepository.findAll()
                .stream()
                .map(this::convertToDto)
                .collect(Collectors.toList());
    }

    @Override
    @Transactional(readOnly = true)
    public ProductDto getProductById(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Product not found with id: " + id));
        return convertToDto(product);
    }

    @Override
    @Transactional
    public ProductDto createProduct(ProductDto productDto) {
        Product product = convertToEntity(productDto);
        Product savedProduct = productRepository.save(product);
        return convertToDto(savedProduct);
    }

    @Override
    @Transactional
    public ProductDto updateProduct(Long id, ProductDto productDto) {
        Product existingProduct = productRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Product not found with id: " + id));

        existingProduct.setName(productDto.getName());
        existingProduct.setDescription(productDto.getDescription());
        existingProduct.setPrice(productDto.getPrice());

        Product updatedProduct = productRepository.save(existingProduct);
        return convertToDto(updatedProduct);
    }

    @Override
    @Transactional
    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
    }

    private ProductDto convertToDto(Product product) {
        return ProductDto.builder()
                .id(product.getId())
                .name(product.getName())
                .description(product.getDescription())
                .price(product.getPrice())
                .createdAt(product.getCreatedAt())
                .updatedAt(product.getUpdatedAt())
                .build();
    }

    private Product convertToEntity(ProductDto productDto) {
        return Product.builder()
                .name(productDto.getName())
                .description(productDto.getDescription())
                .price(productDto.getPrice())
                .build();
    }
}

8. REST Controller (ProductController.java)

package com.example.demo.controller;

import com.example.demo.model.ProductDto;
import com.example.demo.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public ResponseEntity<List<ProductDto>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductDto> getProductById(@PathVariable Long id) {
        return ResponseEntity.ok(productService.getProductById(id));
    }

    @PostMapping
    public ResponseEntity<ProductDto> createProduct(@Valid @RequestBody ProductDto productDto) {
        ProductDto createdProduct = productService.createProduct(productDto);
        return new ResponseEntity<>(createdProduct, HttpStatus.CREATED);
    }

    @PutMapping("/{id}")
    public ResponseEntity<ProductDto> updateProduct(
            @PathVariable Long id,
            @Valid @RequestBody ProductDto productDto) {
        return ResponseEntity.ok(productService.updateProduct(id, productDto));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}

9. Main Application Class (DemoApplication.java)

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

10. Initial Data (data.sql) - Optional

INSERT INTO products (name, description, price, created_at, updated_at) 
VALUES 
('Laptop', 'High performance laptop', 999.99, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('Smartphone', 'Latest smartphone model', 699.99, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('Headphones', 'Noise cancelling headphones', 199.99, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

How to Run and Test

  1. Start the Spring Boot application:bashCopymvn spring-boot:run
  2. The API will be available at:
    • Base URL: http://localhost:8080/api/products
    • H2 Console: http://localhost:8080/h2-console (JDBC URL: jdbc:h2:mem:testdb)
  3. Endpoints:
    • GET /api/products - Get all products
    • GET /api/products/{id} - Get a single product
    • POST /api/products - Create a new product
    • PUT /api/products/{id} - Update a product
    • DELETE /api/products/{id} - Delete a product

Front End

Here's a complete complete CRUD (Create, Read, Update, Delete) application using Angular 17+ and Angular Material.

Prerequisites

  • Node.js (v18+ recommended)
  • npm (v9+ recommended) or yarn
  • Angular CLI (v17+)

Project Structure

Step 1: Create a New Angular Project

ng new angular-material-crud --standalone --style=scss --routing
cd angular-material-crud

Step 2: Install Angular Material

ng add @angular/material

Choose a prebuilt theme (e.g., "Indigo/Pink") and set up browser animations.

Step 3: Create a Core Module (Optional but Recommended)

ng g m core

Add essential services and interceptors here.

Step 4: Create a Shared Module

ng g m shared

Add reusable components, directives, and pipes here.

Step 5: Create a Feature Module for Your CRUD Operations

ng g m products --route products --module app

Step 6: Set Up Angular Material Modules

In your shared.module.ts (or directly in your feature module):

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatCardModule } from '@angular/material/card';
import { MatToolbarModule } from '@angular/material/toolbar';

const materialModules = [
  MatTableModule,
  MatButtonModule,
  MatIconModule,
  MatDialogModule,
  MatFormFieldModule,
  MatInputModule,
  MatSnackBarModule,
  MatProgressSpinnerModule,
  MatCardModule,
  MatToolbarModule
];

@NgModule({
  declarations: [],
  imports: [CommonModule, ...materialModules],
  exports: [...materialModules]
})
export class SharedModule {}

Step 7: Create a Model Interface

Create src/app/products/models/product.model.ts:

export interface Product {
  id?: string;
  name: string;
  description: string;
  price: number;
  createdAt?: Date;
  updatedAt?: Date;
}

Step 8: Create a Service

Generate a service for your CRUD operations:

ng g s products/services/product --skip-tests

Implement the service (product.service.ts):

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../../environments/environment';
import { Product } from '../models/product.model';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  private apiUrl = `${environment.apiUrl}/products`;

  constructor(private http: HttpClient) {}

  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }

  getProduct(id: string): Observable<Product> {
    return this.http.get<Product>(`${this.apiUrl}/${id}`);
  }

  createProduct(product: Product): Observable<Product> {
    return this.http.post<Product>(this.apiUrl, product);
  }

  updateProduct(id: string, product: Product): Observable<Product> {
    return this.http.put<Product>(`${this.apiUrl}/${id}`, product);
  }

  deleteProduct(id: string): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }
}

Step 9: Create Components

Generate components for listing, creating, and editing products:

ng g c products/components/product-list --skip-tests
ng g c products/components/product-form --skip-tests

Step 10: Implement Product List Component

product-list.component.ts:

import { Component, OnInit } from '@angular/core';
import { ProductService } from '../../services/product.service';
import { Product } from '../../models/product.model';
import { MatDialog } from '@angular/material/dialog';
import { ProductFormComponent } from '../product-form/product-form.component';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html',
  styleUrls: ['./product-list.component.scss']
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  displayedColumns: string[] = ['name', 'description', 'price', 'actions'];
  loading = true;

  constructor(
    private productService: ProductService,
    private dialog: MatDialog,
    private snackBar: MatSnackBar
  ) {}

  ngOnInit(): void {
    this.loadProducts();
  }

  loadProducts(): void {
    this.loading = true;
    this.productService.getProducts().subscribe({
      next: (products) => {
        this.products = products;
        this.loading = false;
      },
      error: () => {
        this.loading = false;
        this.snackBar.open('Error loading products', 'Close', { duration: 3000 });
      }
    });
  }

  openAddDialog(): void {
    const dialogRef = this.dialog.open(ProductFormComponent, {
      width: '500px'
    });

    dialogRef.afterClosed().subscribe(result => {
      if (result) {
        this.loadProducts();
      }
    });
  }

  openEditDialog(product: Product): void {
    const dialogRef = this.dialog.open(ProductFormComponent, {
      width: '500px',
      data: { product }
    });

    dialogRef.afterClosed().subscribe(result => {
      if (result) {
        this.loadProducts();
      }
    });
  }

  deleteProduct(id: string): void {
    if (confirm('Are you sure you want to delete this product?')) {
      this.productService.deleteProduct(id).subscribe({
        next: () => {
          this.snackBar.open('Product deleted successfully', 'Close', { duration: 3000 });
          this.loadProducts();
        },
        error: () => {
          this.snackBar.open('Error deleting product', 'Close', { duration: 3000 });
        }
      });
    }
  }
}

product-list.component.html:

<div class="container">
  <mat-toolbar color="primary">
    <span>Products</span>
    <span class="spacer"></span>
    <button mat-raised-button color="accent" (click)="openAddDialog()">
      <mat-icon>add</mat-icon> Add Product
    </button>
  </mat-toolbar>

  <mat-card>
    <mat-card-content>
      @if (loading) {
        <div class="loading-spinner">
          <mat-spinner></mat-spinner>
        </div>
      } @else {
        <table mat-table [dataSource]="products" class="mat-elevation-z8">
          <!-- Name Column -->
          <ng-container matColumnDef="name">
            <th mat-header-cell *matHeaderCellDef>Name</th>
            <td mat-cell *matCellDef="let product">{{ product.name }}</td>
          </ng-container>

          <!-- Description Column -->
          <ng-container matColumnDef="description">
            <th mat-header-cell *matHeaderCellDef>Description</th>
            <td mat-cell *matCellDef="let product">{{ product.description }}</td>
          </ng-container>

          <!-- Price Column -->
          <ng-container matColumnDef="price">
            <th mat-header-cell *matHeaderCellDef>Price</th>
            <td mat-cell *matCellDef="let product">{{ product.price | currency }}</td>
          </ng-container>

          <!-- Actions Column -->
          <ng-container matColumnDef="actions">
            <th mat-header-cell *matHeaderCellDef>Actions</th>
            <td mat-cell *matCellDef="let product">
              <button mat-icon-button color="primary" (click)="openEditDialog(product)">
                <mat-icon>edit</mat-icon>
              </button>
              <button mat-icon-button color="warn" (click)="deleteProduct(product.id!)">
                <mat-icon>delete</mat-icon>
              </button>
            </td>
          </ng-container>

          <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
          <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
        </table>
      }
    </mat-card-content>
  </mat-card>
</div>

Step 11: Implement Product Form Component

product-form.component.ts:

import { Component, Inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Product } from '../../models/product.model';
import { ProductService } from '../../services/product.service';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  selector: 'app-product-form',
  templateUrl: './product-form.component.html',
  styleUrls: ['./product-form.component.scss']
})
export class ProductFormComponent implements OnInit {
  productForm: FormGroup;
  isEditMode = false;
  loading = false;

  constructor(
    private fb: FormBuilder,
    private productService: ProductService,
    private dialogRef: MatDialogRef<ProductFormComponent>,
    @Inject(MAT_DIALOG_DATA) public data: { product: Product },
    private snackBar: MatSnackBar
  ) {
    this.productForm = this.fb.group({
      name: ['', Validators.required],
      description: ['', Validators.required],
      price: ['', [Validators.required, Validators.min(0)]]
    });
  }

  ngOnInit(): void {
    if (this.data?.product) {
      this.isEditMode = true;
      this.productForm.patchValue(this.data.product);
    }
  }

  onSubmit(): void {
    if (this.productForm.valid) {
      this.loading = true;
      const productData = this.productForm.value;

      const operation = this.isEditMode
        ? this.productService.updateProduct(this.data.product.id!, productData)
        : this.productService.createProduct(productData);

      operation.subscribe({
        next: () => {
          this.snackBar.open(
            `Product ${this.isEditMode ? 'updated' : 'created'} successfully`,
            'Close',
            { duration: 3000 }
          );
          this.dialogRef.close(true);
        },
        error: () => {
          this.snackBar.open(
            `Error ${this.isEditMode ? 'updating' : 'creating'} product`,
            'Close',
            { duration: 3000 }
          );
          this.loading = false;
        }
      });
    }
  }
}

product-form.component.html:

<h2 mat-dialog-title>{{ isEditMode ? 'Edit' : 'Add' }} Product</h2>

<mat-dialog-content>
  <form [formGroup]="productForm" (ngSubmit)="onSubmit()">
    <mat-form-field appearance="fill" class="full-width">
      <mat-label>Name</mat-label>
      <input matInput formControlName="name" required>
      @if (productForm.get('name')?.hasError('required')) {
        <mat-error>Name is required</mat-error>
      }
    </mat-form-field>

    <mat-form-field appearance="fill" class="full-width">
      <mat-label>Description</mat-label>
      <textarea matInput formControlName="description" required></textarea>
      @if (productForm.get('description')?.hasError('required')) {
        <mat-error>Description is required</mat-error>
      }
    </mat-form-field>

    <mat-form-field appearance="fill" class="full-width">
      <mat-label>Price</mat-label>
      <input matInput type="number" formControlName="price" required>
      @if (productForm.get('price')?.hasError('required')) {
        <mat-error>Price is required</mat-error>
      }
      @if (productForm.get('price')?.hasError('min')) {
        <mat-error>Price must be positive</mat-error>
      }
    </mat-form-field>
  </form>
</mat-dialog-content>

<mat-dialog-actions align="end">
  <button mat-button mat-dialog-close>Cancel</button>
  <button 
    mat-raised-button 
    color="primary" 
    (click)="onSubmit()"
    [disabled]="!productForm.valid || loading"
  >
    @if (loading) {
      <mat-spinner diameter="20"></mat-spinner>
    } @else {
      {{ isEditMode ? 'Update' : 'Save' }}
    }
  </button>
</mat-dialog-actions>

Step 12: Update the Products Routing Module

products-routing.module.ts:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ProductListComponent } from './components/product-list/product-list.component';

const routes: Routes = [
  { path: '', component: ProductListComponent }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class ProductsRoutingModule {}

Step 13: Update the Products Module

products.module.ts:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProductsRoutingModule } from './products-routing.module';
import { ProductListComponent } from './components/product-list/product-list.component';
import { ProductFormComponent } from './components/product-form/product-form.component';
import { SharedModule } from '../shared/shared.module';
import { HttpClientModule } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    ProductListComponent,
    ProductFormComponent
  ],
  imports: [
    CommonModule,
    ProductsRoutingModule,
    SharedModule,
    HttpClientModule,
    ReactiveFormsModule
  ]
})
export class ProductsModule {}

Step 14: Add Styles

Add to styles.scss:

@use '@angular/material' as mat;

@include mat.core();

$angular-material-crud-primary: mat.define-palette(mat.$indigo-palette);
$angular-material-crud-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
$angular-material-crud-warn: mat.define-palette(mat.$red-palette);

$angular-material-crud-theme: mat.define-light-theme((
  color: (
    primary: $angular-material-crud-primary,
    accent: $angular-material-crud-accent,
    warn: $angular-material-crud-warn,
  )
));

@include mat.all-component-themes($angular-material-crud-theme);

html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }

.container {
  padding: 20px;
}

.full-width {
  width: 100%;
}

.loading-spinner {
  display: flex;
  justify-content: center;
  padding: 20px;
}

.spacer {
  flex: 1 1 auto;
}

Step 15: Set Up Environment Variables

environment.ts:

export const environment = {
  production: false,
  apiUrl: 'http://localhost:8080/api' // Replace with your API URL
};

Step 16: Update App Component

app.component.html:

<mat-toolbar color="primary">
  <span>Angular Material CRUD</span>
</mat-toolbar>

<main>
  <router-outlet></router-outlet>
</main>

Step 17: Run the Application

ng serve

Navigate to http://localhost:4200/products to see your CRUD application in action.

Leave a Reply

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