EngineeringSenior DeveloperTechnical Sessions

Session 11: Full-Stack Integration (.NET + Frontend)

Mục tiêu: Kết nối .NET backend với React/Next.js frontend, pattern cho authentication, real-time, và deployment.


1. API Design: REST vs gRPC vs GraphQL

So Sánh

FeatureRESTgRPCGraphQL
ProtocolHTTP/1.1, JSONHTTP/2, ProtobufHTTP, JSON
ContractOpenAPI/Swagger.proto filesSchema + SDL
PerformanceTốtTốt nhất (~10x)Tốt
StreamingSSE/WebSocketBidirectionalSubscriptions
Browser✅ Native⚠️ grpc-web✅ Native
Use casePublic APIsService-to-serviceComplex queries
.NET supportASP.NET CoreGrpc.AspNetCoreHotChocolate

REST API Best Practices cho Frontend

csharp
// Consistent API response format
public class ApiResponse<T>
{
    public T? Data { get; init; }
    public bool Success { get; init; }
    public string? Message { get; init; }
    public Dictionary<string, string[]>? Errors { get; init; }
}

public class PagedResponse<T> : ApiResponse<IReadOnlyList<T>>
{
    public int Page { get; init; }
    public int PageSize { get; init; }
    public int TotalCount { get; init; }
    public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
    public bool HasNext => Page < TotalPages;
    public bool HasPrevious => Page > 1;
}

// Controller
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class ProductsController(IMediator mediator) : ControllerBase
{
    /// <summary>Get products with filtering and pagination</summary>
    [HttpGet]
    [ProducesResponseType<PagedResponse<ProductDto>>(200)]
    public async Task<IActionResult> GetAll([FromQuery] GetProductsQuery query)
    {
        var result = await mediator.Send(query);
        return Ok(result);
    }
}

OpenAPI Codegen - Shared Types

bash
# Generate TypeScript client từ .NET OpenAPI spec
npx openapi-typescript-codegen \
  --input http://localhost:5001/swagger/v1/swagger.json \
  --output ./src/api/generated \
  --client axios

# Hoặc dùng orval (popular hơn, hỗ trợ TanStack Query)
npx orval --config ./orval.config.ts
typescript
// orval.config.ts
export default {
  api: {
    input: 'http://localhost:5001/swagger/v1/swagger.json',
    output: {
      target: './src/api/generated.ts',
      client: 'react-query',  // Auto-generate TanStack Query hooks!
      mode: 'tags-split',
    },
  },
};

// Generated output - zero manual typing:
// export const useGetProducts = (params?: GetProductsParams) => {
//   return useQuery({ queryKey: ['products', params], queryFn: ... });
// };

2. Authentication Flow: .NET + Frontend

Pattern 1: JWT (.NET API + React SPA)

React App (Vite)                    .NET API
     │                                  │
     │  POST /api/auth/login            │
     │  { email, password }             │
     │─────────────────────────────────→│
     │                                  │ Validate credentials
     │  { accessToken, refreshToken }   │ Generate tokens
     │←─────────────────────────────────│
     │                                  │
     │  Store tokens in memory          │
     │  (KHÔNG localStorage!)           │
     │                                  │
     │  GET /api/products               │
     │  Authorization: Bearer <token>   │
     │─────────────────────────────────→│
     │                                  │ Validate JWT
     │  { data: [...] }                 │
     │←─────────────────────────────────│
tsx
// React: Auth context + interceptor
const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const accessTokenRef = useRef<string | null>(null); // Memory only!

  const login = async (email: string, password: string) => {
    const response = await api.post('/auth/login', { email, password });
    accessTokenRef.current = response.data.accessToken;
    setUser(response.data.user);
    // Refresh token → httpOnly cookie (set by server)
  };

  // Axios interceptor: auto-attach token
  useEffect(() => {
    const interceptor = api.interceptors.request.use((config) => {
      if (accessTokenRef.current) {
        config.headers.Authorization = `Bearer ${accessTokenRef.current}`;
      }
      return config;
    });

    // Auto-refresh interceptor
    const responseInterceptor = api.interceptors.response.use(
      (response) => response,
      async (error) => {
        if (error.response?.status === 401) {
          try {
            const { data } = await api.post('/auth/refresh');
            accessTokenRef.current = data.accessToken;
            error.config.headers.Authorization = `Bearer ${data.accessToken}`;
            return api(error.config); // Retry original request
          } catch {
            setUser(null);
            accessTokenRef.current = null;
          }
        }
        return Promise.reject(error);
      }
    );

    return () => {
      api.interceptors.request.eject(interceptor);
      api.interceptors.response.eject(responseInterceptor);
    };
  }, []);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}
csharp
// .NET: Set httpOnly cookie cho refresh token
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request)
{
    var user = await _authService.ValidateCredentials(request);
    var tokens = _tokenService.GenerateTokens(user);

    // httpOnly cookie - Next.js tự động gửi
    Response.Cookies.Append("refresh-token", tokens.RefreshToken, new CookieOptions
    {
        HttpOnly = true,
        Secure = true,
        SameSite = SameSiteMode.Strict,
        Expires = DateTimeOffset.UtcNow.AddDays(7),
        Path = "/api/auth"
    });

    return Ok(new { accessToken = tokens.AccessToken, user = user.ToDto() });
}
tsx
// Next.js: Auth.js (NextAuth v5) integration
// auth.ts
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    CredentialsProvider({
      async authorize(credentials) {
        // Call .NET API
        const res = await fetch(`${process.env.API_URL}/api/auth/login`, {
          method: 'POST',
          body: JSON.stringify(credentials),
          headers: { 'Content-Type': 'application/json' },
        });

        if (!res.ok) return null;
        const data = await res.json();
        return { ...data.user, accessToken: data.accessToken };
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) token.accessToken = user.accessToken;
      return token;
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken as string;
      return session;
    },
  },
});

// Middleware protection
// middleware.ts
export { auth as middleware } from '@/auth';
export const config = {
  matcher: ['/dashboard/:path*', '/orders/:path*'],
};

3. Real-Time: SignalR + React

csharp
// .NET: SignalR Hub
public class OrderHub : Hub
{
    public async Task JoinOrderRoom(string orderId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");
    }

    public async Task LeaveOrderRoom(string orderId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"order-{orderId}");
    }
}

// Broadcast từ service
public class OrderService(IHubContext<OrderHub> hubContext)
{
    public async Task UpdateStatus(Guid orderId, OrderStatus status)
    {
        // Update DB...
        
        // Notify connected clients
        await hubContext.Clients
            .Group($"order-{orderId}")
            .SendAsync("OrderStatusChanged", new { orderId, status });
    }
}
tsx
// React: SignalR hook
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr';

function useSignalR(hubUrl: string) {
  const [connection, setConnection] = useState<HubConnection | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const conn = new HubConnectionBuilder()
      .withUrl(hubUrl, {
        accessTokenFactory: () => getAccessToken(), // JWT
      })
      .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
      .configureLogging(LogLevel.Information)
      .build();

    conn.start()
      .then(() => {
        setIsConnected(true);
        setConnection(conn);
      })
      .catch(console.error);

    conn.onreconnecting(() => setIsConnected(false));
    conn.onreconnected(() => setIsConnected(true));

    return () => { conn.stop(); };
  }, [hubUrl]);

  return { connection, isConnected };
}

// Usage in component
function OrderTracker({ orderId }: { orderId: string }) {
  const { connection, isConnected } = useSignalR('/hubs/orders');
  const [status, setStatus] = useState<string>('');
  const queryClient = useQueryClient();

  useEffect(() => {
    if (!connection) return;

    connection.invoke('JoinOrderRoom', orderId);

    connection.on('OrderStatusChanged', (data) => {
      setStatus(data.status);
      // Invalidate related queries
      queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
    });

    return () => {
      connection.invoke('LeaveOrderRoom', orderId);
      connection.off('OrderStatusChanged');
    };
  }, [connection, orderId]);

  return (
    <div>
      <StatusBadge connected={isConnected} />
      <p>Current status: {status}</p>
    </div>
  );
}

4. Deployment: Docker + Azure

Docker Multi-Stage Build

dockerfile
# ---- .NET API ----
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS api-build
WORKDIR /src
COPY src/Api/*.csproj ./
RUN dotnet restore
COPY src/Api/ ./
RUN dotnet publish -c Release -o /app/publish

# ---- React Frontend ----
FROM node:20-alpine AS frontend-build
WORKDIR /app
COPY src/Web/package*.json ./
RUN npm ci
COPY src/Web/ ./
RUN npm run build

# ---- Final ----
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=api-build /app/publish ./
COPY --from=frontend-build /app/dist ./wwwroot
EXPOSE 8080
ENTRYPOINT ["dotnet", "Api.dll"]

Azure Container Apps

yaml
# docker-compose.azure.yml
services:
  api:
    image: myregistry.azurecr.io/myapp-api:latest
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__Default=<from-keyvault>
    ports:
      - "8080:8080"

  web:
    image: myregistry.azurecr.io/myapp-web:latest
    environment:
      - VITE_API_URL=https://api.myapp.com
    ports:
      - "3000:3000"

5. Monorepo Structure

my-project/
├── src/
│   ├── Api/                    ← .NET Web API
│   │   ├── Api.csproj
│   │   └── Program.cs
│   ├── Domain/                 ← .NET Domain layer
│   │   └── Domain.csproj
│   ├── Infrastructure/         ← .NET Infrastructure
│   │   └── Infrastructure.csproj
│   └── Web/                    ← React + Vite (hoặc Next.js)
│       ├── package.json
│       ├── vite.config.ts
│       └── src/
├── shared/
│   └── openapi.json            ← OpenAPI spec (auto-generated)
├── scripts/
│   ├── generate-client.sh      ← Generate TS client
│   └── dev.sh                  ← Start both servers
├── docker-compose.yml
├── MyProject.sln
└── README.md
json
// Web/package.json scripts
{
  "scripts": {
    "dev": "vite",
    "dev:full": "concurrently \"dotnet run --project ../Api\" \"vite\"",
    "generate-api": "orval --config orval.config.ts",
    "build": "tsc && vite build",
    "preview": "vite preview"
  }
}

Tổng Kết Session 11

ConceptKey Takeaway
API DesignREST cho public API, OpenAPI codegen để share types
Auth (SPA)JWT in memory + refresh token in httpOnly cookie
Auth (Next.js)Auth.js/NextAuth + middleware protection
SignalRReal-time updates, auto-reconnect, group-based messaging
DockerMulti-stage builds cho .NET + Frontend
MonorepoShared OpenAPI spec, concurrent dev servers

Bài tập: Setup monorepo với .NET API + React Vite, configure proxy, implement JWT auth flow, và dùng OpenAPI codegen (orval) để generate API client.